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

107
src/css/adinplay.scss Normal file
View File

@@ -0,0 +1,107 @@
#aip_gdpr {
&,
* {
text-shadow: none !important;
pointer-events: all;
color: #111 !important;
}
#aip_gdpr_banner {
padding: 5px 0;
}
#aip_gdpr_message {
padding: 0px 15px;
}
}
#adinplayVideoContainer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 20000;
background: rgba($mainBgColor, 0.9);
pointer-events: all;
cursor: default;
display: flex;
justify-content: center;
align-items: center;
*,
& {
pointer-events: all;
}
&:not(.visible) {
display: none;
}
&.waitingForFinish {
.videoInner {
@include BorderRadius(4px);
overflow: hidden;
&::after {
content: " ";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba($mainBgColor, 0.9) uiResource("loading.svg") center center / #{D(60px)} no-repeat;
@include InlineAnimation(0.2s ease-in-out) {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
}
}
@include InlineAnimation(1s ease-in-out) {
0% {
background: rgba($mainBgColor, 0.1);
}
100% {
background: rgba($mainBgColor, 0.9);
}
}
.adInner {
@include BoxShadow3D(lighten($mainBgColor, 15));
@include BorderRadius(4px);
@include S(padding, 15px);
// max-width: 960px;
display: block !important;
.topbar {
display: grid;
grid-template-columns: 1fr auto;
@include S(margin-bottom, 15px);
@include S(grid-column-gap, 10px);
.desc {
@include TextShadow3D(#fff);
@include PlainText;
}
button.getOnSteam {
@include Text;
}
}
.videoInner {
// width: 960px;
// height: 570px;
// min-width: 960px;
// min-height: 570px;
background: darken($mainBgColor, 1);
display: block !important;
}
}
}

13
src/css/animations.scss Normal file
View File

@@ -0,0 +1,13 @@
@include MakeAnimationWrappedEvenOdd(0.2s ease-in-out, "changeAnim") {
0% {
transform: scale(1, 1);
}
50% {
transform: scale(1.03, 1.03);
}
100% {
transform: scale(1, 1);
}
}

View File

@@ -0,0 +1,67 @@
#applicationError {
z-index: 9999;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $mainBgColor;
color: #333;
display: flex;
flex-direction: column;
align-content: center;
align-items: center;
justify-content: center;
@include S(padding, 30px);
@include Text;
text-align: center;
h1 {
@include TextShadow3D(#ff0b40);
@include S(margin-top, 20px);
@include S(margin-bottom, 30px);
@include SuperHeading;
@include S(font-size, 35px);
}
.desc {
// color: rgba(#fff, 0.6);
color: $themeColor;
text-align: left;
@include PlainText;
font-weight: bold;
a {
cursor: pointer;
pointer-events: all;
font-weight: bold;
display: block;
@include TextShadow3D(#ff0b40);
@include S(margin-top, 10px);
}
display: block;
@include S(max-width, 350px);
width: 100%;
}
.details {
font-size: 11px;
line-height: 15px;
color: #888;
font-family: monospace;
text-align: left;
@include S(padding, 6px);
@include BorderRadius(2px);
@include BoxShadow3D(#eee);
position: absolute;
@include S(bottom, 25px);
left: 50%;
transform: translateX(-50%);
max-width: calc(100vw - 40px);
box-sizing: border-box;
@include BreakText;
min-width: 300px;
}
}

662
src/css/common.scss Normal file
View File

@@ -0,0 +1,662 @@
// Common classes and style
* {
margin: 0;
padding: 0;
touch-action: pan-x pan-y !important;
pointer-events: none;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
}
html,
body {
overscroll-behavior: contain;
overflow: hidden;
font-family: $mainFont;
font-synthesis: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
html {
position: fixed;
// scroll-behavior: smooth;
background: $mainBgColor;
// Disable zooming and thus
-ms-touch-action: pan-x, pan-y;
touch-action: pan-x, pan-y;
-ms-content-zooming: none;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: #dee1ea;
}
body {
color: #555;
user-select: none;
-moz-user-select: none;
-ms-user-select: none;
background: inherit !important;
text-transform: none;
white-space: normal;
word-break: normal;
word-spacing: normal;
word-wrap: break-word;
font-style: normal;
line-break: auto;
font-stretch: 100%;
text-rendering: optimizeLegibility;
text-decoration: none;
text-size-adjust: 100%;
letter-spacing: normal;
scrollbar-width: 6px;
-webkit-font-smoothing: antialiased;
// -webkit-overflow-scrolling: touch; /* stop scrolling immediately */
-webkit-touch-callout: none; /* prevent callout to copy image, etc when tap to hold */
-webkit-text-size-adjust: none; /* prevent webkit from resizing text to fit */
// Internet explorer
scrollbar-face-color: #888;
scrollbar-track-color: rgba(255, 255, 255, 0.1);
overflow: hidden;
@include Text;
// For recording the bg video
// filter: blur(5px);
// &::after {
// position: fixed;
// top: 0;
// left: 0;
// right: 0;
// bottom: 0;
// z-index: 9999;
// content: " ";
// background: rgba($ingameHudBg, 0.5);
// }
}
// Dirty hack
* {
@include TextShadow3DImpl;
}
img {
-webkit-touch-callout: none; /* prevent callout to copy image, etc when tap to hold */
}
i {
font-style: normal;
}
b,
strong {
font-weight: normal;
}
u,
a {
text-decoration: none;
}
input,
textarea,
select {
font-size: inherit;
font-weight: inherit;
font-family: inherit;
line-height: inherit;
}
button {
background: transparent;
border: 0;
pointer-events: all;
cursor: pointer;
position: relative;
@include TextShadow3D;
&.prefab_BuyButtonWithResources {
display: flex;
box-sizing: border-box;
@include S(padding, 6px, 4px);
// letter-spacing: 0;
background-color: color($cyan, 400);
flex-direction: row;
justify-content: center;
align-items: center;
@include S(width, 85px);
&.tooExpensive {
color: $colorRedBright;
background-color: #555;
cursor: default;
}
.cost_entry {
display: flex;
flex-grow: 1;
justify-content: center;
align-items: center;
}
b {
display: flex;
flex-grow: 1;
justify-content: center;
align-items: center;
}
&.tooExpensive {
cursor: default !important;
background-color: #565859 !important;
b {
color: $colorRedBright !important;
}
.cost_entry {
opacity: 0.6;
}
}
}
}
.styledButton {
background: $themeColor;
text-transform: uppercase;
box-sizing: content-box;
@include S(padding, 3px, 10px);
@include IncreasedClickArea(10px);
@include BorderRadius(4px);
@include TextShadow3D(#fff, $borderColor: #28292a);
@include ButtonText;
@include Button3D($accentColorBright);
border: #{D(1px)} solid rgba(0, 10, 20, 0.2);
@include S(border-bottom-width, 2px);
color: $accentColorDark;
letter-spacing: 0.05em !important;
box-shadow: 0 #{D(1px)} #{D(2px)} 0 rgba(0, 10, 20, 0.2);
.keybinding {
@include S(bottom, -2.5px);
@include S(right, -2px);
}
}
::selection {
background: $colorGreenBright; /* WebKit/Blink Browsers */
}
::-moz-selection {
background: $colorGreenBright; /* Gecko Browsers */
}
input[type="text"],
input[type="email"] {
@include S(padding, 11px, 12px);
@include S(margin, 10px, 0);
border: 0;
cursor: text;
display: block;
text-align: left;
box-sizing: border-box;
background: lighten($mainBgColor, 8);
color: #eee;
text-align: left;
user-select: text !important;
pointer-events: all !important;
@include Text;
@include IncreasedClickArea(15px);
@include BorderRadius(4px);
&::placeholder {
color: #fff;
opacity: 0.4;
}
transition: background-color 0.4s ease-in-out !important;
@include TextShadow3D(#fff);
@include BoxShadow3D(lighten($mainBgColor, 30));
&:focus {
@include BoxShadow3D(lighten($mainBgColor, 35));
}
&.errored {
@include BoxShadow3D(mix(lighten($mainBgColor, 30), #f77, 25%));
&:focus {
@include BoxShadow3D(mix(lighten($mainBgColor, 50), #f77, 25%));
}
}
&.input-token {
@include SuperHeading;
text-align: center;
@include S(letter-spacing, 30px);
@include S(padding-left, 30px);
}
}
a {
color: $themeColor;
}
button,
input,
select,
textarea,
a {
&:focus {
outline: none;
}
font-family: inherit;
font-weight: inherit;
pointer-events: all;
}
a {
text-decoration: none;
cursor: pointer;
pointer-events: all;
}
i {
font-style: normal;
}
input {
user-select: text;
-moz-user-select: text;
pointer-events: all;
cursor: text;
border-radius: 0;
}
canvas {
pointer-events: all;
image-rendering: auto;
// &.smoothed {
// }
// &.unsmoothed {
// }
letter-spacing: 0 !important;
transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.fontPreload {
position: absolute;
top: -100px;
left: -100px;
}
// Scrollbar
::-webkit-scrollbar {
@include S(width, 6px);
@include S(height, 6px);
}
::-webkit-scrollbar-track {
background: rgba(#000, 0.05);
}
::-webkit-scrollbar-thumb {
border-radius: 4px;
background: #cdd0d4;
}
::-webkit-scrollbar-thumb:hover {
background: #d8dce0;
}
#uiTestPlaybackCursor {
position: fixed;
top: 100px;
left: 100px;
z-index: 9999;
border-radius: 50%;
background: rgba(255, 255, 0, 0.4);
width: 24px;
height: 24px;
border: 3px solid rgba(0, 0, 0, 0.5);
margin-top: -12px;
margin-left: -12px;
box-sizing: border-box;
}
.pressed {
transform: scale(0.95) !important;
animation: none !important;
}
.pressedSmallElement {
transform: scale(0.88) !important;
animation: none !important;
}
.spritesheetImage {
display: block;
position: absolute;
background-repeat: no-repeat;
z-index: 1;
}
.inlineTextIconSprite {
position: relative;
vertical-align: middle;
display: inline-block;
}
.badged {
color: color($purple, 300);
}
.prefab_LoadingTextWithAnim,
.prefab_LoadingTextWithAnimDelayed {
display: inline-flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
@include Text;
@include TextShadow3D;
opacity: 1;
z-index: 20;
color: #393747;
&::after {
content: " ";
background: uiResource("loading.svg") center center / contain no-repeat;
@include S(width, 15px);
@include S(height, 15px);
@include S(margin-top, 1px);
@include S(margin-left, 5px);
display: inline-block;
vertical-align: middle;
}
}
.prefab_LoadingTextWithAnimDelayed {
@include InlineAnimation(0.6s ease-in-out) {
0% {
opacity: 0;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
.prefab_FeatureComingSoon {
position: relative;
&::after {
@include S(top, -5px);
@include S(left, -5px);
@include S(right, -5px);
@include S(bottom, -5px);
content: "Coming soon!";
z-index: 10000;
background: rgba(lighten($mainBgColor, 0), 0.4);
@include BorderRadius(4px);
position: absolute;
display: flex;
justify-content: center;
align-items: center;
pointer-events: all;
@include PlainText;
text-transform: uppercase;
}
opacity: 0.6;
> * {
opacity: 0.5 !important;
}
}
.prefab_InfoIcon {
@include S(width, 25px);
@include S(height, 25px);
// background: uiResource("icons_small/info.png") center center / contain no-repeat;
z-index: 100;
opacity: 0.8;
cursor: pointer;
pointer-events: all;
display: inline-block;
position: relative;
@include IncreasedClickArea(10px);
}
.gameState.prefab_LoadingState {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.loadingImage {
background: uiResource("loading.svg") center center / #{D(60px)} no-repeat;
width: 100%;
display: flex;
flex-grow: 1;
}
.loadingStatus {
position: absolute;
@include S(left, 20px);
@include S(right, 20px);
@include S(bottom, 30px);
@include Text;
@include TextShadow3D(#aaa);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
> .bar {
display: none;
@include S(margin-top, 15px);
width: 80vw;
@include BoxShadow3D(lighten($mainBgColor, 10), $size: 1px);
position: relative;
@include TextShadow3D(#fff);
height: 2px;
.inner {
position: absolute !important;
top: 0;
left: 0;
bottom: 0;
z-index: 1;
@include BoxShadow3D($themeColor, $size: 1px);
@include BorderRadius(4px);
transform-origin: 0% 50%;
@include InlineAnimation(1.3s ease-in-out infinite) {
0% {
background-color: darken($themeColor, 5);
transform: none;
}
50% {
background-color: lighten($themeColor, 10);
transform: scale(1.01);
}
100% {
background-color: darken($themeColor, 5);
transform: none;
}
}
}
.status {
display: none;
position: relative;
z-index: 2;
display: inline-flex;
@include S(padding, 5px);
@include PlainText;
}
}
}
}
.grow {
flex-grow: 1;
}
.checkbox {
$bgColor: darken($mainBgColor, 0);
background-color: $bgColor;
@include S(width, 45px);
@include S(height, 20px);
display: flex;
@include S(padding, 3px);
box-sizing: content-box;
cursor: pointer;
pointer-events: all;
transition: opacity 0.2s ease-in-out, background-color 0.4s ease-in-out, box-shadow 0.4s ease-in-out !important;
position: relative;
@include BorderRadius(20px);
@include IncreasedClickArea(10px);
@include BoxShadow3D($bgColor, $size: 2px);
&.loading {
opacity: 0.2;
}
.knob {
@include S(width, 20px);
@include S(height, 20px);
display: inline-block;
transition: margin-left 0.4s ease-in-out !important;
background: #fff;
position: relative;
@include BorderRadius(20px);
@include BoxShadow3D(#fff, $size: 1px);
}
&.checked {
background-color: $themeColor;
@include BoxShadow3D($themeColor, $size: 2px);
.knob {
@include S(margin-left, 25px);
}
}
}
.keybinding {
background: #fff;
text-transform: uppercase;
@include S(padding, 2px, 1px, 2px);
@include PlainText;
@include BorderRadius(2px);
&,
> span {
@include S(font-size, 9px);
@include S(line-height, 11px);
font-weight: bold !important;
text-shadow: none !important;
// font-family: Arial, sans-serif !important;
}
font-weight: bold;
color: $accentColorDark;
text-align: center;
justify-content: center;
align-items: center;
@include S(min-width, 12px);
display: inline-flex;
position: absolute;
@include S(bottom, 0px);
@include S(right, 0px);
z-index: 999;
box-sizing: border-box;
@include S(height, 12px);
overflow: hidden;
border: #{D(1px)} solid $accentColorDark;
.keybinding_space {
@include S(font-size, 17px);
@include S(line-height, 11px);
@include S(margin-top, -12px);
}
}
.xpaystation-widget-lightbox {
z-index: 19999;
.xpaystation-widget-lightbox-overlay {
background: rgba($mainBgColor, 0.94);
}
&,
iframe {
pointer-events: all;
user-select: all;
}
}
iframe {
pointer-events: all;
user-select: all;
}
// Steam overlay fiy
#steamOverlayCanvasFix {
position: fixed;
top: 0px;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
opacity: 0.01;
pointer-events: none;
z-index: -1;
}
.sentry-error-embed-wrapper {
z-index: 10000;
background: rgba(0, 0, 0, 0.9);
* {
text-shadow: none !important;
pointer-events: all;
}
}
.cpmsrendertarget {
&,
* {
pointer-events: all;
}
background: rgba($mainBgColor, 0.94) !important;
.cpmsvideoclosebanner {
font-family: GameFont !important;
font-size: 16px !important;
border-radius: 2px !important;
background: $themeColor !important;
@include BoxShadow3D(darken($mainBgColor, 12));
color: #eee !important;
&:active {
@include BoxShadow3D(darken($mainBgColor, 12), $size: 1px);
transform: translateY(2px);
}
}
}

43
src/css/dynamic_ui.scss Normal file
View File

@@ -0,0 +1,43 @@
// Removes the unit (px, %, etc) from a value
@function strip-unit($number) {
@if type-of($number) == "number" and not unitless($number) {
@return $number / ($number * 0 + 1);
}
@return $number;
}
// Helper method to scale a value, for use in calc() etc
@function D($v) {
$baseValue: strip-unit($v) * 1px;
@return calc(#{$baseValue} * var(--ui-scale));
}
// Helper method to scale the font size
@mixin ScaleFont($fontSize, $lineHeight) {
font-size: D($fontSize * $mainFontScale);
line-height: D($lineHeight * $mainFontScale);
}
// Helper method to scale a property value
@mixin S($propName, $v1, $v2: "", $v3: "", $v4: "", $important: false) {
$impSuffix: "";
@if $important == true {
$impSuffix: "!important";
}
$v1: D($v1);
@if $v2 != "" {
$v2: D($v2);
}
@if $v3 != "" {
$v3: D($v3);
}
@if $v4 != "" {
$v4: D($v4);
}
#{$propName}: #{$v1} #{$v2} #{$v3} #{$v4} #{$impSuffix};
}

31
src/css/game_state.scss Normal file
View File

@@ -0,0 +1,31 @@
$gameStateTransition: 0.2s ease-out;
@mixin StateAnim($properties...) {
transition: all $gameStateTransition;
transition-property: $properties;
}
.gameState {
display: block;
// background: $mainBgColor;
height: 100%;
width: 100%;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
overflow: hidden !important;
@include Text;
@include StateAnim(opacity, transform, filter);
opacity: 0;
// transform: scaleX(0.99) skewX(1deg) translate(1%, 0.5%);
&.arrived {
opacity: 1;
filter: none !important;
transform: none;
}
}

22
src/css/icons.scss Normal file
View File

@@ -0,0 +1,22 @@
// $icons: ;
// @each $icon in $icons {
// [data-icon="#{$icon}"] {
// background-image: uiResource("res/ui/#{$icon}");
// }
// }
$buildings: belt, cutter, miner, mixer, painter, rotater, splitter, stacker, trash, underground_belt;
@each $building in $buildings {
[data-icon="building_tutorials/#{$building}.png"] {
background-image: uiResource("res/ui/building_tutorials/#{$building}.png") !important;
}
}
$upgrades: belt, miner, painting, processors;
@each $upgrade in $upgrades {
[data-icon="upgrades/#{$upgrade}.png"] {
background-image: uiResource("res/ui/upgrades/#{$upgrade}.png") !important;
}
}

View File

@@ -0,0 +1,8 @@
#ingame_HUD_BetaOverlay {
position: fixed;
@include S(top, 10px);
@include S(right, 15px);
color: $colorRedBright;
@include Heading;
text-transform: uppercase;
}

View File

@@ -0,0 +1,8 @@
body.ingameDialogOpen {
#ingame_Canvas,
#ingame_HUD_GameMenu,
#ingame_HUD_KeybindingOverlay,
#ingame_HUD_buildings_toolbar {
filter: blur(5px);
}
}

View File

@@ -0,0 +1,35 @@
#ingame_HUD_building_placer {
position: fixed;
@include S(bottom, 60px);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
@include S(padding, 6px);
justify-content: center;
align-items: center;
background-color: $ingameHudBg;
@include S(border-radius, 4px);
background: #333;
@include S(width, 300px);
.buildingLabel {
@include PlainText;
color: #fff;
text-transform: uppercase;
@include S(margin-bottom, 2px);
}
.instructions,
.description {
text-align: center;
color: mix($accentColorDark, $accentColorBright, 50%);
@include SuperSmallText;
}
@include StyleBelowWidth(700px) {
display: none !important;
}
}

View File

@@ -0,0 +1,181 @@
#ingame_HUD_buildings_toolbar {
position: fixed;
@include S(bottom, 0px);
left: 50%;
transform: translateX(-50%);
$toolbarBg: rgba($accentColorBright, 0.9);
display: flex;
flex-direction: column;
background-color: $toolbarBg;
// border: $ingameHudBorder;
border-bottom-width: 0;
@include S(border-radius, 4px, 4px, 0, 0);
// box-shadow: 0 0 0 #{D(2px)} rgba(darken($toolbarBg, 20), 0.5);
transition: transform 0.12s ease-in-out;
&:not(.visible) {
transform: translateX(-50%) translateY(#{D(100px)});
}
.buildings {
display: grid;
grid-auto-flow: column;
@include S(padding, 0, 5px);
.building {
color: $accentColorDark;
display: flex;
flex-direction: column;
position: relative;
align-items: center;
justify-content: center;
@include S(padding, 5px);
@include S(padding-bottom, 7px);
$buildingIconSize: 32px;
&:not(.unlocked) {
@include S(width, 30px);
.tooltip {
display: none !important;
}
.keybinding,
.iconWrap {
opacity: 0.01;
}
&::before {
opacity: 0.5;
content: " ";
background: uiResource("locked_building.png") center center / #{D(20px)} #{D(20px)}
no-repeat;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 4;
}
}
// &:first-child .tooltip {
// display: flex;
// }
pointer-events: all;
transition: background-color 0.1s ease-in-out;
&.unlocked:hover {
background: rgba($accentColorDark, 0.1);
cursor: pointer;
}
.iconWrap {
position: relative;
@include S(width, $buildingIconSize);
@include S(height, $buildingIconSize);
@include S(margin-top, 3px);
@include S(margin-bottom, 6px);
}
.label {
@include SuperSmallText;
display: none;
font-weight: bold;
text-transform: uppercase;
}
.keybinding {
// position: relative;
right: 50%;
transform: translateX(50%);
background: transparent;
border: 0;
@include S(bottom, 2pxpx);
}
&[data-tilewidth="2"] {
.iconWrap {
@include S(width, 2 * $buildingIconSize);
}
}
&:last-child {
border: none;
}
.tooltip {
position: absolute;
pointer-events: none;
background: #333;
@include S(padding, 7px);
bottom: calc(100% + #{D(10px)});
left: 50%;
transform: translateX(-50%);
box-sizing: content-box;
@include SuperSmallText;
@include S(width, 200px);
@include S(border-radius, 4px);
box-shadow: #{D(1px)} #{D(1px)} 0 0 rgba(0, 10, 25, 0.2);
display: none;
z-index: 9999;
flex-direction: column;
.title {
color: #fff;
@include PlainText;
text-transform: uppercase;
margin-bottom: 5px;
}
.desc {
color: #aaa;
@include SuperSmallText;
margin-bottom: 10px;
strong {
color: #fff;
}
}
.tutorialImage {
display: inline-block;
@include S(width, 200px);
@include S(height, 200px);
@include S(border-radius, 4px);
background-size: contain;
background-repeat: no-repeat;
}
&::after {
top: 100%;
left: 50%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-top-color: #333;
@include S(border-width, 5px);
transform: translateX(-50%);
}
}
&:hover .tooltip {
display: flex;
@include InlineAnimation(0.5s ease-in-out) {
90% {
opacity: 0;
}
0% {
transform: translate(-50%, 5%) scale(0.9);
opacity: 0;
}
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
.ingameDialog {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: all;
background: $modalDialogBg;
display: flex;
align-items: center;
justify-content: center;
&.visible {
.dialogInner {
opacity: 1;
}
}
.dialogInner {
transition: opacity 0.2s ease-in-out;
opacity: 0;
}
> .dialogInner {
background: #fff;
@include S(min-width, 500px);
max-width: calc(100vw - #{D(50px)});
max-height: calc(100vh - #{D(50px)});
@include S(border-radius, 4px);
display: flex;
flex-direction: column;
@include S(padding, 15px);
pointer-events: all;
> .title {
@include Heading;
margin: 0;
text-transform: uppercase;
display: grid;
grid-template-columns: 1fr auto;
@include S(margin-bottom, 10px);
> .closeButton {
opacity: 0.7;
@include S(width, 20px);
@include S(height, 20px);
background: uiResource("icons/close.png") center center / 60% no-repeat;
cursor: pointer;
pointer-events: all;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 0.4;
}
}
}
> .content {
overflow-y: auto;
pointer-events: all;
}
}
}

View File

@@ -0,0 +1,59 @@
#ingame_HUD_GameMenu {
position: absolute;
top: 0;
left: 50%;
display: grid;
transform: translateX(-50%);
@include S(grid-gap, 3px);
grid-auto-flow: column;
button {
background: $colorGreenBright;
@include PlainText;
color: #fff;
border-color: rgba(0, 0, 0, 0.1);
@include S(padding, 5px, 5px, 5px);
transition: all 0.12s ease-in-out;
transition-property: opacity, transform;
&:hover {
opacity: 0.9;
transform: translateY(3px);
}
@include IncreasedClickArea(10px);
@include ButtonText;
border: #{D(2px)} solid rgba(0, 10, 20, 0.2);
@include S(border-width, 2px);
border-radius: 0 0 #{D(4px)} #{D(4px)};
@include S(border-top-width, 10px);
@include S(padding-left, 30px);
@include S(margin-top, -5px);
@include S(letter-spacing, 1px, $important: true);
background: center #{D(10px)} / #{D(20px)} no-repeat;
@include S(min-height, 47px);
&[data-button-id="shop"] {
background-color: rgb(141, 70, 223);
background-image: uiResource("icons/shop.png");
}
&[data-button-id="stats"] {
background-color: rgb(53, 235, 113);
background-image: uiResource("icons/statistics.png");
}
.keybinding {
border: 0;
color: #fff;
border-top-left-radius: 0;
border-top-right-radius: 0;
bottom: unset;
// background: rgba(0, 10, 20, 0.5);
background: transparent;
@include S(top, -5px);
right: unset;
left: 50%;
transform: translateX(-50%);
}
}
}

View File

@@ -0,0 +1,78 @@
#ingame_HUD_KeybindingOverlay {
position: absolute;
@include S(top, 10px);
@include S(left, 10px);
display: flex;
flex-direction: column;
align-items: flex-start;
> .binding {
display: inline-grid;
@include PlainText;
align-items: center;
@include S(margin-bottom, 3px);
grid-auto-flow: column;
@include S(grid-gap, 2px);
i {
display: inline-block;
@include S(height, 10px);
width: 1px;
@include S(margin, 0, 3px);
background-color: #ccc;
transform: rotate(10deg);
// @include S(margin, 0, 3px);
}
code {
position: relative;
top: unset;
left: unset;
margin: 0;
&.rightMouse {
background: #fff uiResource("icons/mouse_right.png") center center / 85% no-repeat;
}
&.leftMouse {
background: #fff uiResource("icons/mouse_left.png") center center / 85% no-repeat;
}
}
label {
color: $accentColorDark;
@include SuperSmallText;
text-transform: uppercase;
@include S(margin-left, 5px);
}
}
&:not(.placementActive) .binding.placementOnly {
display: none;
}
&.placementActive .binding.noPlacementOnly {
display: none;
}
.binding.placementOnly,
&:not(.placementActive) .binding.noPlacementOnly {
transform-origin: 0% 50%;
@include InlineAnimation(0.3s ease-in-out) {
0% {
color: $colorRedBright;
transform: scale(1.2);
}
}
}
.shift .keybinding {
transition: all 0.1s ease-in-out;
transition-property: background-color, color, border-color;
background: $colorRedBright;
border-color: $colorRedBright;
color: #fff;
}
&.shiftDown .shift .keybinding {
border-color: darken($colorRedBright, 40);
}
}

View File

@@ -0,0 +1,180 @@
#ingame_HUD_Shop {
.content {
@include S(padding-right, 10px);
display: flex;
flex-direction: column;
.upgrade {
display: grid;
grid-template-columns: auto 1fr auto;
background: #eee;
@include S(border-radius, 3px);
@include S(margin-bottom, 4px);
@include S(padding, 5px, 10px);
@include S(grid-row-gap, 5px);
@include S(height, 95px);
grid-template-rows: #{D(20px)} auto;
&:last-child {
margin-bottom: 0;
}
.title {
grid-column: 2 / 3;
grid-row: 1 / 2;
@include Heading;
display: flex;
align-items: center;
.tier {
@include S(margin-left, 5px);
background: $colorGreenBright;
@include S(border-radius, 4px);
text-transform: uppercase;
@include PlainText;
color: #fff;
font-weight: bold;
@include S(margin-top, 1px);
@include S(padding, 0px, 5px);
&[data-tier="0"] {
background-color: rgb(73, 186, 190);
}
&[data-tier="1"] {
background-color: rgb(73, 94, 190);
}
&[data-tier="2"] {
background-color: rgb(186, 73, 190);
}
&[data-tier="3"] {
background-color: rgb(96, 190, 73);
}
&[data-tier="4"] {
background-color: rgb(190, 91, 73);
}
&[data-tier="5"] {
background-color: rgb(219, 184, 29);
}
&[data-tier="6"] {
background-color: rgb(190, 73, 73);
}
}
}
.icon {
@include S(width, 40px);
@include S(height, 40px);
background: center center / contain no-repeat;
align-self: center;
justify-self: center;
grid-column: 1 / 2;
grid-row: 1 / 4;
@include S(margin-right, 20px);
opacity: 0.2;
}
.description {
grid-column: 3 / 4;
grid-row: 1 / 2;
@include PlainText;
color: #aaa;
align-self: start;
justify-self: end;
}
.requirements {
grid-column: 2 / 3;
grid-row: 3 / 4;
display: grid;
grid-auto-flow: column;
@include S(grid-gap, 15px);
justify-content: start;
.requirement {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
canvas {
@include S(width, 40px);
@include S(height, 40px);
}
.amount {
@include S(margin-top, 4px);
z-index: 10;
@include SuperSmallText;
letter-spacing: 0;
background: #e2e4e6;
@include S(line-height, 13px);
@include S(border-radius, 2px);
@include S(padding, 0, 2px, 3px);
position: relative;
text-align: center;
@include S(min-width, 50px);
overflow: hidden;
.progressBar {
bottom: 0;
left: 0;
right: 0;
top: 0;
@include S(border-radius, 2px);
position: absolute;
display: inline-block;
z-index: -1;
transition: all 0.2s ease-in-out;
transition-property: width, background-color;
background: #bdbfca;
&.complete {
background-color: $colorGreenBright;
}
}
}
}
}
button.buy {
grid-column: 3 / 4;
grid-row: 3 / 4;
align-self: end;
justify-self: end;
// @include S(padding, 4px, 5px);
// @include PlainText;
background-color: $colorGreenBright;
color: #fff;
transition: all 0.2s ease-in-out;
transition-property: background-color, opacity;
&:not(.buyable) {
background-color: #aaa;
cursor: default;
pointer-events: none;
opacity: 0.3;
}
}
&.maxLevel {
button.buy {
opacity: 0 !important;
}
.requirements {
display: none;
}
.description {
// grid-column: 2 / 4;
// grid-row: 2 / 3;
align-self: end;
justify-self: center;
color: $colorGreenBright;
text-transform: uppercase;
@include S(margin-top, 20px);
}
}
}
}
}

View File

@@ -0,0 +1,134 @@
#ingame_HUD_UnlockNotification {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(#333538, 0.95) uiResource("dialog_bg_pattern.png") top left / #{D(10px)} repeat;
display: flex;
justify-content: center;
align-items: flex-start;
pointer-events: all;
@include InlineAnimation(0.1s ease-in-out) {
0% {
opacity: 0;
}
}
.dialog {
background: rgba(#333539, 0.5);
@include S(padding, 30px);
@include InlineAnimation(0.5s ease-in-out) {
0% {
opacity: 0;
}
}
color: #fff;
text-align: center;
.title,
.subTitle {
@include SuperHeading;
text-transform: uppercase;
@include S(font-size, 50px);
@include InlineAnimation(0.5s ease-in-out) {
0% {
transform: translateY(-50vh);
}
50% {
transform: translateY(5vh);
}
75% {
transform: translateY(-2vh);
}
}
}
.subTitle {
@include Heading;
background: $colorGreenBright;
display: inline-block;
@include S(padding, 1px, 6px);
@include S(margin, 20px, 0, 20px);
@include S(border-radius, 4px);
@include InlineAnimation(0.5s ease-in-out) {
0% {
transform: translateY(-60vh);
}
50% {
transform: translateY(6vh);
}
75% {
transform: translateY(-3vh);
}
}
}
.contents {
@include S(width, 400px);
@include InlineAnimation(0.5s ease-in-out) {
0% {
transform: translateX(-100vw);
}
50% {
transform: translateX(5vw);
}
75% {
transform: translateX(-2vw);
}
}
display: grid;
grid-template-columns: auto auto;
align-items: center;
justify-content: center;
@include S(grid-gap, 10px);
.reward {
grid-column: 1 / 3;
@include InlineAnimation(0.5s ease-in-out) {
0% {
transform: translateX(200vw);
}
50% {
transform: translateX(-10vw);
}
75% {
transform: translateX(4vw);
}
}
}
.buildingExplanation {
@include S(width, 200px);
@include S(height, 200px);
display: inline-block;
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
@include S(border-radius, 4px);
box-shadow: #{D(2px)} #{D(3px)} 0 0 rgba(0, 0, 0, 0.15);
}
}
button.close {
border: 0;
@include InlineAnimation(0.5s ease-in-out) {
0% {
transform: translateY(50vh);
}
50% {
transform: translateY(-5vh);
}
75% {
transform: translateY(2vh);
}
}
@include S(margin-top, 30px);
}
}
}

50
src/css/main.scss Normal file
View File

@@ -0,0 +1,50 @@
// Control here whether to inline all resources or instead load them
@function uiResource($pth) {
@if (str-index($string: $pth, $substring: ".noinline")) {
@return resolve($pth);
}
@return inline($pth);
}
@import "icons";
@import "trigonometry";
@import "material_colors";
@import "dynamic_ui";
@import "variables";
@import "mixins";
@import "common";
@import "animations";
@import "game_state";
@import "application_error";
@import "textual_game_state";
@import "adinplay";
@import "states/preload";
@import "states/main_menu";
@import "states/ingame";
@import "ingame_hud/buildings_toolbar";
@import "ingame_hud/building_placer";
@import "ingame_hud/beta_overlay";
@import "ingame_hud/keybindings_overlay";
@import "ingame_hud/unlock_notification";
@import "ingame_hud/shop";
@import "ingame_hud/game_menu";
@import "ingame_hud/blur_overlay";
@import "ingame_hud/dialogs";
// Z-Index
$elements: ingame_Canvas, ingame_HUD_building_placer_overlay, ingame_HUD_building_placer,
ingame_HUD_buildings_toolbar, ingame_HUD_GameMenu, ingame_HUD_KeybindingOverlay, ingame_HUD_Shop,
ingame_HUD_BetaOverlay, ingame_HUD_UnlockNotification;
$zindex: 100;
@each $elem in $elements {
##{$elem} {
z-index: $zindex;
}
$zindex: $zindex + 10;
}

View File

@@ -0,0 +1,319 @@
//
// color palette
// sass-lint:disable hex-length
@function color($color, $value: 500) {
@return map-get($color, $value);
}
$red: (
50: #ffebee,
100: #ffcdd2,
200: #ef9a9a,
300: #e57373,
400: #ef5350,
500: #f44336,
600: #e53935,
700: #d32f2f,
800: #c62828,
900: #b71c1c,
a100: #ff8a80,
a200: #ff5252,
a400: #ff1744,
a700: #d50000,
);
$pink: (
50: #fce4ec,
100: #f8bbd0,
200: #f48fb1,
300: #f06292,
400: #ec407a,
500: #e91e63,
600: #d81b60,
700: #c2185b,
800: #ad1457,
900: #880e4f,
a100: #ff80ab,
a200: #ff4081,
a400: #f50057,
a700: hsl(333, 84%, 42%),
);
$purple: (
50: #f3e5f5,
100: #e1bee7,
200: #ce93d8,
300: #ba68c8,
400: #ab47bc,
500: #9c27b0,
600: #8e24aa,
700: #7b1fa2,
800: #6a1b9a,
900: #4a148c,
a100: #ea80fc,
a200: #e040fb,
a400: #d500f9,
a700: #aa00ff,
);
$deep-purple: (
50: #ede7f6,
100: #d1c4e9,
200: #b39ddb,
300: #9575cd,
400: #7e57c2,
500: #673ab7,
600: #5e35b1,
700: #512da8,
800: #4527a0,
900: #311b92,
a100: #b388ff,
a200: #7c4dff,
a400: #651fff,
a700: #6200ea,
);
$indigo: (
50: #e8eaf6,
100: #c5cae9,
200: #9fa8da,
300: #7986cb,
400: #5c6bc0,
500: #3f51b5,
600: #3949ab,
700: #303f9f,
800: #283593,
900: #1a237e,
a100: #8c9eff,
a200: #536dfe,
a400: #3d5afe,
a700: #304ffe,
);
$blue: (
50: #e3f2fd,
100: #bbdefb,
200: #90caf9,
300: #64b5f6,
400: #42a5f5,
500: #2196f3,
600: #1e88e5,
700: #1976d2,
800: #1565c0,
900: #0d47a1,
a100: #82b1ff,
a200: #448aff,
a400: #2979ff,
a700: #2962ff,
);
$light-blue: (
50: #e1f5fe,
100: #b3e5fc,
200: #81d4fa,
300: #4fc3f7,
400: #29b6f6,
500: #03a9f4,
600: #039be5,
700: #0288d1,
800: #0277bd,
900: #01579b,
a100: #80d8ff,
a200: #40c4ff,
a400: #00b0ff,
a700: #0091ea,
);
$cyan: (
50: #e0f7fa,
100: #b2ebf2,
200: #80deea,
300: #4dd0e1,
400: #26c6da,
500: #00bcd4,
600: #00acc1,
700: #0097a7,
800: #00838f,
900: #006064,
a100: #84ffff,
a200: #18ffff,
a400: #00e5ff,
a700: #00b8d4,
);
$teal: (
50: #e0f2f1,
100: #b2dfdb,
200: #80cbc4,
300: #4db6ac,
400: #26a69a,
500: #009688,
600: #00897b,
700: #00796b,
800: #00695c,
900: #004d40,
a100: #a7ffeb,
a200: #64ffda,
a400: #1de9b6,
a700: #00bfa5,
);
$green: (
50: #e8f5e9,
100: #c8e6c9,
200: #a5d6a7,
300: #81c784,
400: #66bb6a,
500: #4caf50,
600: #43a047,
700: #388e3c,
800: #2e7d32,
900: #1b5e20,
a100: #b9f6ca,
a200: #69f0ae,
a400: #00e676,
a700: #00c853,
);
$light-green: (
50: #f1f8e9,
100: #dcedc8,
200: #c5e1a5,
300: #aed581,
400: #9ccc65,
500: #8bc34a,
600: #7cb342,
700: #689f38,
800: #558b2f,
900: #33691e,
a100: #ccff90,
a200: #b2ff59,
a400: #76ff03,
a700: #64dd17,
);
$lime: (
50: #f9fbe7,
100: #f0f4c3,
200: #e6ee9c,
300: #dce775,
400: #d4e157,
500: #cddc39,
600: #c0ca33,
700: #afb42b,
800: #9e9d24,
900: #827717,
a100: #f4ff81,
a200: #eeff41,
a400: #c6ff00,
a700: #aeea00,
);
$yellow: (
50: #fffde7,
100: #fff9c4,
200: #fff59d,
300: #fff176,
400: #ffee58,
500: #ffeb3b,
600: #fdd835,
700: #fbc02d,
800: #f9a825,
900: #f57f17,
a100: #ffff8d,
a200: #ffff00,
a400: #ffea00,
a700: #ffd600,
);
$amber: (
50: #fff8e1,
100: #ffecb3,
200: #ffe082,
300: #ffd54f,
400: #ffca28,
500: #ffc107,
600: #ffb300,
700: #ffa000,
800: #ff8f00,
900: #ff6f00,
a100: #ffe57f,
a200: #ffd740,
a400: #ffc400,
a700: #ffab00,
);
$orange: (
50: #fff3e0,
100: #ffe0b2,
200: #ffcc80,
300: #ffb74d,
400: #ffa726,
500: #ff9800,
600: #fb8c00,
700: #f57c00,
800: #ef6c00,
900: #e65100,
a100: #ffd180,
a200: #ffab40,
a400: #ff9100,
a700: #ff6d00,
);
$deep-orange: (
50: #fbe9e7,
100: #ffccbc,
200: #ffab91,
300: #ff8a65,
400: #ff7043,
500: #ff5722,
600: #f4511e,
700: #e64a19,
800: #d84315,
900: #bf360c,
a100: #ff9e80,
a200: #ff6e40,
a400: #ff3d00,
a700: #dd2c00,
);
$brown: (
50: #efebe9,
100: #d7ccc8,
200: #bcaaa4,
300: #a1887f,
400: #8d6e63,
500: #795548,
600: #6d4c41,
700: #5d4037,
800: #4e342e,
900: #3e2723,
);
$grey: (
50: #fafafa,
100: #f5f5f5,
200: #eeeeee,
300: #e0e0e0,
400: #bdbdbd,
500: #9e9e9e,
600: #757575,
700: #616161,
800: #424242,
900: #212121,
);
$blue-grey: (
50: #eceff1,
100: #cfd8dc,
200: #b0bec5,
300: #90a4ae,
400: #78909c,
500: #607d8b,
600: #546e7a,
700: #455a64,
800: #37474f,
900: #263238,
);

379
src/css/mixins.scss Normal file
View File

@@ -0,0 +1,379 @@
// ----------------------------------------
/* Forces an element to get rendered on its own layer, increasing
the performance when animated. Use only transform and opacity in animations! */
@mixin FastAnimation {
// will-change: transform, opacity;
transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
// Helper which includes the translateZ webkit fix, use together with Fast animation
// $hardwareAcc: translateZ(0);
$hardwareAcc: null;
// ----------------------------------------
/** Increased click area for this element, helpful on mobile */
@mixin IncreasedClickArea($size) {
&::after {
content: "";
position: absolute;
top: #{D(-$size)};
bottom: #{D(-$size)};
left: #{D(-$size)};
right: #{D(-$size)};
// background: rgba(255, 0, 0, 0.3);
}
}
button,
.increasedClickArea {
position: relative;
@include IncreasedClickArea(15px);
}
// ----------------------------------------
/* Duplicates an animation and adds two classes .<classPrefix>Even and .<classPrefix>Odd which uses the
animation. This can be used to replay the animation by toggling between the classes, because
it is not possible to restart a css animation */
@mixin MakeAnimationWrappedEvenOdd($duration, $classPrefix: "anim", $childSelector: "") {
$animName: autogen_anim_#{unique-id()};
@at-root {
@keyframes #{$animName}_even {
@content;
}
@keyframes #{$animName}_odd {
@content;
}
}
&.#{$classPrefix}Even #{$childSelector} {
animation: #{$animName}_even $duration;
}
&.#{$classPrefix}Odd #{$childSelector} {
animation: #{$animName}_odd $duration;
}
}
// ----------------------------------------
/* Allows to use and define an animation without specifying its name */
@mixin InlineAnimation($duration) {
$animName: autogen_anim_#{unique-id()};
@at-root {
@keyframes #{$animName} {
@content;
}
}
animation: $animName $duration !important;
}
// ----------------------------------------
/* Animation prefab for a double bounce pop-in animation, useful for dialogs */
@mixin DoubleBounceAnim($duration: 0.5s ease-in-out, $amount: 0.2, $initialOpacity: 0) {
@include InlineAnimation($duration) {
0% {
opacity: $initialOpacity;
transform: scale(0) $hardwareAcc;
}
25% {
opacity: 0.5;
transform: scale(1 + $amount) $hardwareAcc;
}
50% {
opacity: 1;
transform: scale(1 - $amount * 0.5) $hardwareAcc;
}
75% {
transform: scale(1 + $amount * 0.25) $hardwareAcc;
}
100% {
transform: scale(1) $hardwareAcc;
}
}
opacity: 1;
}
// ----------------------------------------
/* Define a style which is only applied in horizontal mode */
@mixin HorizontalStyle {
@include AppendGlobal(".h") {
@content;
}
}
// ----------------------------------------
/* Define a style which is only applied in vertical mode */
@mixin VerticalStyle {
@include AppendGlobal(".v") {
@content;
}
}
// ----------------------------------------
/* Define a style which is only while the hardware keyboard is open */
@mixin AndroidHwKeyboardOpen {
@include AppendGlobal(".kb") {
@content;
}
}
// ----------------------------------------
/* Automatically transforms the game state if a hardware keyboard is open */
@mixin TransformToMatchKeyboard {
transition: transform 0.2s ease-in-out;
@include AndroidHwKeyboardOpen {
@include VerticalStyle {
transform: translateY(#{D(-125px)}) $hardwareAcc;
}
@include HorizontalStyle {
transform: translateY(#{D(-100px)}) $hardwareAcc;
}
}
}
// ----------------------------------------
/* Define a style which is only applied when the viewport is at least X pixels wide */
@mixin StyleAtWidth($minW) {
@media (min-width: #{$minW}) {
@content;
}
}
// ----------------------------------------
/* Define a style which is only applied when the viewport is at least X pixels height */
@mixin StyleAtHeight($minH) {
@media (min-height: #{$minH}) {
@content;
}
}
// ----------------------------------------
/* Define a style which is only applied when the viewport has at least the given dimensions */
@mixin StyleAtDims($minW, $minH) {
@media (min-height: #{$minH}) and (min-width: #{$minW}) {
@content;
}
}
// ----------------------------------------
/* Define a style which is only applied when the viewport has at maximum the given dimensions */
@mixin StyleBelowDims($maxW, $maxH) {
@media (max-height: #{$maxH}) and (max-width: #{$maxW}) {
@content;
}
}
// ----------------------------------------
/* Define a style which is only applied when the viewport has at maximum the given height */
@mixin StyleBelowHeight($maxH) {
@media (max-height: #{$maxH}) {
@content;
}
}
// ----------------------------------------
/* Define a style which is only applied when the viewport has at maximum the given width */
@mixin StyleBelowWidth($maxW) {
@media (max-width: #{$maxW}) {
@content;
}
}
// ----------------------------------------
// Dynamic graphics quality styles
@mixin BoxShadow3D($bgColor, $size: 3px, $pressEffect: true) {
background-color: $bgColor;
$borderSize: 1.5px;
$borderColor: rgb(18, 20, 24);
// box-shadow: 0 0 0 D($borderSize) $borderColor, 0 D($size) 0 0px rgba(mix(darken($bgColor, 9), #b0e2ff, 95%), 1),
// 0 D($size) 0 D($borderSize) $borderColor;
// box-shadow: 0 0 0 D($borderSize) $borderColor, 0 D($size) 0 D($borderSize) $borderColor,
// D(-$size * 1.5) D($size * 2) 0 D($borderSize) rgba(0, 0, 0, 0.1);
// transition: box-shadow 0.1s ease-in-out;
// @if $pressEffect {
// &.pressed {
// transform: none !important;
// $pSize: max(0, $size - 1.5px);
// transition: none !important;
// box-shadow: 0 0 0 D($borderSize) $borderColor, 0 D($pSize) 0 0px rgba(mix(darken($bgColor, 9), #b0e2ff, 95%), 1),
// 0 D($pSize) 0 D($borderSize) $borderColor;
// top: D($size - $pSize);
// }
// }
}
@mixin BorderRadius($v1: 2px, $v2: "", $v3: "", $v4: "") {
@include S(border-radius, $v1, $v2, $v3, $v4);
}
@mixin BoxShadow($x, $y, $blur, $offset, $color) {
box-shadow: D($x) D($y) D($blur) D($offset) $color;
}
@mixin DropShadow($yOffset: 2px, $blur: 2px, $amount: 0.2) {
@include BoxShadow(0, $yOffset, $blur, 0, rgba(#000, $amount));
}
@mixin TextShadow($yOffset: 2px, $blur: 1px, $amount: 0.6) {
text-shadow: 0 D($yOffset) D($blur) rgba(#000, $amount);
}
@mixin Button3D($bgColor, $pressEffect: true) {
@include BoxShadow3D($bgColor, 2px, $pressEffect);
}
@mixin ButtonDisabled3D($bgColor) {
@include BoxShadow3D($bgColor, 0.5px, false);
}
@mixin BoxShadowInset($bgColor, $size: 3px) {
background-color: $bgColor;
$borderSize: 1px;
$borderColor: rgb(15, 19, 24);
box-shadow: 0 0 0 D($borderSize) $borderColor, 0 D($size) 0 rgba(#fff, 0.07);
border-top: D($size) solid rgba(#000, 0.1);
//, 0 D($size) 0 0px rgba(mix(darken($bgColor, 9), #b0e2ff, 95%), 1),
// 0 D($size + $borderSize) 0 0 $borderColor;
}
@mixin TextShadow3D($color: rgb(222, 234, 238), $borderColor: #000) {
// @if $borderColor != #000 {
// @include TextShadow3DImpl($color: $color, $borderColor: $borderColor);
// }
color: $color;
}
@mixin TextShadow3DImpl(
$color: rgb(222, 234, 238),
$scale: 1,
$additionalShadowAlpha: 1,
$borderColor: #222428
) {
// color: $text3dColor;
$borderColor: rgba(15, 18, 23, 0.9);
// $shadowColor: darken($color, 40%);
$border: 0.07em;
$borderMid: $border * 1.14;
$drop1: $borderMid + 0.02;
$drop2: $borderMid + 0.06em;
// text-shadow: #{$border} #{$border} 0 $borderColor, #{-$border} #{$border} 0 $borderColor, #{$border} #{-$border} 0 $borderColor,
// #{-$border} #{-$border} 0 $borderColor, 0 #{$borderMid} 0 $borderColor, 0 #{-$borderMid} 0 $borderColor,
// #{$borderMid} 0 0 $borderColor, #{-$borderMid} 0 0 $borderColor, 0 #{$drop1} 0 $borderColor, #{$borderMid} #{$drop1} 0 $borderColor,
// #{-$borderMid} #{$drop1} 0 $borderColor, 0 #{$drop2} 0 $borderColor, #{$borderMid} #{$drop2} 0 $borderColor,
// #{-$borderMid} #{$drop2} 0 $borderColor, -0.2em 0.13em 0 rgba(#111, 0.25); // 0px 0.07em 0px $shadowColor,
// 0px 0.15em 0.09em rgba(#333539, $additionalShadowAlpha);;
}
// ----------------------------------------
/* Shine animation prefab, useful for buttons etc. Adds a bright shine which moves over
the button like a reflection. Performance heavy. */
@mixin ShineAnimation($duration, $bgColor, $w: 200px, $shineAlpha: 0.25, $lightenAmount: 7, $bgAnim: true) {
$bgBase: darken($bgColor, 5);
background-color: $bgBase;
@include HighQualityOrMore {
position: relative;
// overflow: hidden;
// overflow: visible;
&:before {
content: " ";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: uiResource("misc/shine_bg.png") 0px center / 100% 100% no-repeat;
@include InlineAnimation($duration ease-in-out infinite) {
0% {
background-position-x: #{D(-$w)};
}
100% {
background-position-x: #{D($w)};
}
}
}
@if ($bgAnim) {
@include InlineAnimation($duration ease-in-out infinite) {
0% {
background-color: $bgBase;
}
50% {
background-color: lighten($bgBase, $lightenAmount);
}
100% {
background-color: $bgBase;
}
}
}
}
}
// ----------------------------------------
/* String replacement */
@function str-replace($string, $search, $replace: "") {
$index: str-index($string, $search);
@if $index {
@return str-slice($string, 1, $index - 1) + $replace +
str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
}
@return $string;
}
@mixin BounceInFromSide($mul, $duration: 0.18s ease-in-out) {
@include InlineAnimation($duration) {
0% {
transform: translateY(#{D(-100px * $mul)}) scale(0.9);
opacity: 0;
}
100% {
opacity: 1;
transform: none;
}
}
opacity: 1;
transform: none;
}
@mixin BreakText {
word-wrap: break-word;
word-break: break-all;
overflow-wrap: break-all;
}
@mixin SupportsAndroidNotchQuery {
@supports (color: constant(--notch-inset-left)) {
@content;
}
}
@mixin SupportsiOsNotchQuery {
@supports (color: env(safe-area-inset-left, 0px)) {
@content;
}
}

View File

@@ -0,0 +1,16 @@
#state_InGameState {
.gameLoadingOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
align-items: center;
justify-content: center;
pointer-events: all;
display: flex;
background: $mainBgColor;
flex-direction: column;
}
}

View File

@@ -0,0 +1,27 @@
#state_MainMenuState {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
background: uiResource("menu_bg.noinline.jpg") center center / cover no-repeat !important;
.logo {
img {
@include S(width, 350px);
}
}
.mainContainer {
@include S(margin-top, 40px);
display: flex;
.playButton {
@include SuperHeading;
@include S(width, 150px);
@include S(padding, 15px, 20px);
color: #fff;
background-color: $accentColorDark;
}
}
}

100
src/css/states/preload.scss Normal file
View File

@@ -0,0 +1,100 @@
#state_PreloadState {
&.failure {
.loadingImage,
.loadingStatus {
display: none;
}
}
.failureBox {
.logo {
img {
@include S(width, 240px);
}
@include S(margin-bottom, 30px);
}
@include InlineAnimation(0.3s ease-in-out) {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.failureInner {
// background: darken($mainBgColor, 6);
@include S(max-width, 350px);
margin: 0 20px;
text-align: left;
@include BoxShadow3D(#fff);
@include S(padding, 15px);
@include BorderRadius(4px);
@include DropShadow;
.errorHeader {
color: #ef5072;
}
.errorMessage {
@include PlainText;
display: block;
color: #666;
text-align: left;
@include BreakText;
hyphens: auto;
// border: dotted #666;
// @include S(border-width, 1px, 0);
@include S(padding, 10px, 0);
@include S(margin-top, 10px);
}
.supportHelp {
@include S(margin-top, 10px);
@include PlainText;
.email {
color: $themeColor;
cursor: pointer;
pointer-events: all;
}
}
.lower {
display: flex;
align-items: center;
@include S(margin-top, 16px);
i {
flex-grow: 1;
text-align: right;
color: #777;
@include PlainText;
}
button.resetApp {
@include Button3D($colorRedBright);
@include PlainText;
@include S(padding, 5px, 8px, 4px);
}
}
}
}
/* Animations */
.status {
transform: scale(0.7) $hardwareAcc;
opacity: 0;
@include StateAnim(transform, opacity);
}
&.arrived {
.status {
opacity: 1;
transform: none;
}
}
}

View File

@@ -0,0 +1,267 @@
.gameState.textualState {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
$padding: 15px;
.bottomPoppingInNotification {
position: absolute;
left: 50%;
text-align: center;
@include BoxShadow3D(mix(lighten($mainBgColor, 12), $colorRedBright, 50%));
@include S(padding, 10px);
max-width: #{D(280px)};
@include S(bottom, 30px);
box-sizing: border-box;
width: 100%;
@include PlainText;
@include BorderRadius(4px);
$baseTransform: translateX(-50%);
transform-origin: 0% 100%;
transform: translateY(500%);
opacity: 0;
display: block;
@include InlineAnimation(5s ease-in-out) {
0% {
opacity: 0;
transform: scale(0) skew(5deg, 5deg) translateY(100%) $baseTransform;
}
8% {
transform: scale(1.05) translateY(-2%) $baseTransform;
}
12% {
transform: scale(1) $baseTransform;
opacity: 1;
}
97% {
transform: scale(1) $baseTransform;
opacity: 1;
}
100% {
opacity: 0;
transform: scale(0) skew(5deg, 5deg) translateY(100%) $baseTransform;
}
}
}
.widthKeeper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: content-box;
@include S(max-width, 1000px);
@include StyleAtHeight(800px) {
@include S(padding-top, 30px);
}
.headerBar {
display: flex;
@include VerticalStyle {
// margin-top: 1px;
}
// margin-bottom: 15px;
padding: $padding;
$h: 25px;
@include S(min-height, $h);
@include S(max-height, $h);
align-items: center;
justify-content: center;
position: relative;
z-index: 50;
background: transparent;
@include S(padding-top, $padding);
@include S(padding-left, $padding);
@include S(padding-right, $padding);
background-size: calc(100% - #{D(6px)}) 100%;
$paddingBottom: 20px;
@include S(padding-bottom, $paddingBottom);
@include S(margin-bottom, -$h - $padding - $paddingBottom);
h1 {
// text-align: center;
cursor: pointer;
// transform-origin: 0px 50%;
pointer-events: all;
@include S(padding, 5px, 0px, 5px, 30px);
@include S(left, -2px);
@include S(min-width, 100px);
position: relative;
@include IncreasedClickArea(25px);
text-transform: uppercase;
// background: uiResource("back_arrow.png") center center no-repeat;
@include S(background-position-x, -3px);
@include S(background-size, 25px, 25px);
// Due to back button
color: $text3dColor;
@include TextShadow3D($borderColor: #18151d);
@include SuperHeading;
@include StyleBelowWidth(380px) {
@include Heading;
}
}
.grow {
flex-grow: 1;
}
}
.container {
text-align: left;
flex-direction: column;
pointer-events: all;
box-sizing: border-box;
z-index: 25;
position: relative;
@include S(padding-left, 0px);
@include S(padding-right, 0px);
height: 100%;
@include SupportsAndroidNotchQuery {
height: calc(
100% - constant(safe-area-inset-top) - constant(safe-area-inset-bottom) -
var(--notch-inset-top) - var(--notch-inset-bottom)
);
}
@include SupportsiOsNotchQuery {
height: calc(
100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) -
var(--notch-inset-top) - var(--notch-inset-bottom)
);
}
.loadingIndicator {
display: none;
}
.errorIndicator {
display: none;
flex-direction: column;
text-align: center;
.errorInner {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
@include S(max-width, 350px);
strong {
$col: #ff4564;
@include TextShadow3D($col);
@include Heading;
}
i {
@include PlainText;
color: #888;
@include S(margin-top, 10px);
display: inline-block;
}
}
}
.loadingIndicator,
.errorIndicator {
box-sizing: border-box;
justify-content: center;
align-items: center;
height: 100%;
@include S(padding, 30px);
}
// Loading state
&.loading {
.mainContent {
animation: none;
display: none !important;
}
.loadingIndicator {
display: flex;
}
}
// Error state
&.errored {
.mainContent {
animation: none;
display: none !important;
}
.errorIndicator {
animation: none;
display: flex;
}
}
}
.mainContent {
overflow-y: auto !important;
overflow-x: hidden;
@include S(padding, $padding);
height: 100%;
width: 100%;
box-sizing: border-box;
@include InlineAnimation(0.4s ease-in-out) {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.category_label {
display: block;
@include S(margin-top, 40px);
text-transform: uppercase;
&:first-child {
margin-top: 0 !important;
}
@include S(margin-bottom, 16px);
@include Heading;
@include TextShadow3D(#68a1bb, $borderColor: #141718);
}
.cardbox {
@include S(padding, 20px, 15px);
$cardBg: lighten($mainBgColor, 9);
background: $cardBg;
margin-bottom: 15px;
@include S(margin-bottom, 15px);
@include BorderRadius(4px);
@include S(padding-bottom, 14px);
@include BoxShadow3D($cardBg);
&:last-child {
border-bottom: 0;
}
}
}
}
&.hasTitle {
.mainContent {
@include S(padding-top, 70px, $important: true);
}
}
}

66
src/css/trigonometry.scss Normal file
View File

@@ -0,0 +1,66 @@
///////////////////////////////////////////////////////////
// Plain SASS Trigonometry Algorithm in Taylor Expansion //
// //
// Based on //
// http://japborst.net/posts/sass-sines-and-cosines //
///////////////////////////////////////////////////////////
$pi: 3.14159265359;
$_precision: 10;
@function pow($base, $exp) {
$value: $base;
@if $exp > 1 {
@for $i from 2 through $exp {
$value: $value * $base;
}
}
@if $exp < 1 {
@for $i from 0 through -$exp {
$value: $value / $base;
}
}
@return $value;
}
@function fact($num) {
$fact: 1;
@if $num > 0 {
@for $i from 1 through $num {
$fact: $fact * $i;
}
}
@return $fact;
}
@function _to_unitless_rad($angle) {
@if unit($angle) == "deg" {
$angle: $angle / 180deg * $pi;
}
@if unit($angle) == "rad" {
$angle: $angle / 1rad;
}
@return $angle;
}
@function sin($angle) {
$a: _to_unitless_rad($angle);
$sin: $a;
@for $n from 1 through $_precision {
$sin: $sin + (pow(-1, $n) / fact(2 * $n + 1)) * pow($a, (2 * $n + 1));
}
@return $sin;
}
@function cos($angle) {
$a: _to_unitless_rad($angle);
$cos: 1;
@for $n from 1 through $_precision {
$cos: $cos + (pow(-1, $n) / fact(2 * $n)) * pow($a, 2 * $n);
}
@return $cos;
}
@function tan($angle) {
@return sin($angle) / cos($angle);
}

195
src/css/variables.scss Normal file
View File

@@ -0,0 +1,195 @@
// When to reduce control elements size for small devices
$layoutExpandMinWidth: 340px;
// Font sizes and line heights
$superHeadingFontSize: 25px;
$superHeadingLineHeight: 24px;
$breakTooltipShowStatsPx: 1023px;
$headingFontSize: 19px;
$headingLineHeight: 21px;
$textFontSize: 16px;
$textLineHeight: 21px;
$plainTextFontSize: 13px;
$plainTextLineHeight: 17px;
$supersmallTextFontSize: 10px;
$supersmallTextLineHeight: 13px;
$buttonFontSize: 14px;
$buttonLineHeight: 18px;
// Main background color
$mainBgColor: #dee1ea;
// Accent colors
$accentColorBright: #e1e4ed;
$accentColorDark: #7d808a;
$colorGreenBright: #66bb6a;
$colorRedBright: #ef5072;
$themeColor: #393747;
$ingameHudBg: rgba($accentColorBright, 0.9);
$ingameHudBorder: #{D(1.5px)} solid $accentColorDark;
$text3dColor: #f4ffff;
// Dialog properties
$modalDialogBg: rgba(#666a73, 0.8);
$dialogBgColor: lighten($mainBgColor, 10);
$lightFontWeight: normal;
$boldFontWeight: 600;
$iconSizeSmall: 30px;
$iconSizeMedium: 40px;
$iconSizeLarge: 60px;
// Poppins 500
// Rubik 400
// Cairo 400
// Viga 400
// Sniglet 400
$mainFont: "GameFont", sans-serif;
// $mainFont: "DK Canoodle";
// $mainFont: "MADE Florence Sans";
$numberFont: $mainFont;
$textFont: $mainFont;
$mainFontWeight: 400;
$mainFontSpacing: 0.04em;
$mainFontScale: 1;
@mixin DebugText($color) {
// font-size: 3px;
// &,
// * {
// color: $color !important;
// }
}
@mixin SuperSmallText {
@include ScaleFont($supersmallTextFontSize, $supersmallTextLineHeight);
font-weight: $mainFontWeight;
font-family: $mainFont;
letter-spacing: $mainFontSpacing;
@include DebugText(green);
}
@mixin PlainText {
@include ScaleFont($plainTextFontSize, $plainTextLineHeight);
font-weight: $mainFontWeight;
font-family: $mainFont;
letter-spacing: $mainFontSpacing;
@include DebugText(red);
}
@mixin Text {
@include ScaleFont($textFontSize, $textLineHeight);
font-weight: $mainFontWeight;
font-family: $mainFont;
letter-spacing: $mainFontSpacing;
@include DebugText(blue);
}
@mixin Heading {
@include ScaleFont($headingFontSize, $headingLineHeight);
font-weight: $mainFontWeight;
font-family: $mainFont;
letter-spacing: $mainFontSpacing;
@include DebugText(yellow);
}
@mixin SuperHeading {
@include ScaleFont($superHeadingFontSize, $superHeadingLineHeight);
font-weight: $mainFontWeight;
font-family: $mainFont;
letter-spacing: $mainFontSpacing;
@include DebugText(orange);
}
@mixin ButtonText {
@include ScaleFont($buttonFontSize, $buttonLineHeight);
font-weight: $mainFontWeight;
font-family: $mainFont;
letter-spacing: $mainFontSpacing;
@include DebugText(purple);
}
@function str-split($string, $separator) {
// empty array/list
$split-arr: ();
// first index of separator in string
$index: str-index($string, $separator);
// loop through string
@while $index != null {
// get the substring from the first character to the separator
$item: str-slice($string, 1, $index - 1);
// push item to array
$split-arr: append($split-arr, $item);
// remove item and separator from string
$string: str-slice($string, $index + 1);
// find new index of separator
$index: str-index($string, $separator);
}
// add the remaining string to list (the last item)
$split-arr: append($split-arr, $string);
@return $split-arr;
}
@function _first-index($string, $direction: "left") {
@for $i from 1 through str-length($string) {
$index: if($direction == "left", $i, -$i);
@if str-slice($string, $index, $index) != " " {
@return $index;
}
}
@return 0;
}
@function trim($string) {
@return str-slice($string, _first-index($string, "left"), _first-index($string, "right"));
}
@mixin AppendGlobal($prefix) {
$strSelector: quote(&);
$selectors: str-split($strSelector, ",");
$builtSelector: null;
@if (& == null) {
$builtSelector: "html" + $prefix;
} @else {
$builtSelector: ();
// @debug ($strSelector, "->>>", $selectors);
@each $srcSelector in $selectors {
$srcSelector: trim($srcSelector);
// @debug ("___", $srcSelector);
$selector: "html" + $prefix + " " + $srcSelector;
@if str-index($srcSelector, "html.") {
$selector: "html" +
$prefix +
"." +
str-slice($srcSelector, str-index($srcSelector, "html.") + 5);
}
// @debug ("_______", $selector);
$builtSelector: append($builtSelector, $selector, comma);
}
}
@at-root #{$builtSelector} {
@content;
}
}

43
src/html/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<title>shapez.io</title>
<!-- mobile stuff -->
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no, viewport-fit=cover"
/>
<meta name="HandheldFriendly" content="true" />
<meta name="MobileOptimized" content="320" />
<meta name="theme-color" content="#393747" />
<!-- seo -->
<meta name="copyright" content="2020 Tobias Springer IT Solutions and .io Games" />
<meta name="author" content="Tobias Springer, tobias.springer1@gmail.com" />
<meta
name="description"
content="shapez.io is a fun factory base building game about combining shapes - Build the biggest factory you can imagine!"
/>
<meta name="keywords" content="shapez.io, .io games, games, tower defense, factorio, upgrades" />
<meta property="og:title" content="shapez.io" />
<meta
property="og:description"
content="shapez.io is a fun factory base building game about combining shapes - Build the biggest factory you can imagine!"
/>
<meta property="og:url" content="https://shapez.io/" />
<meta property="og:image" content="https://shapez.io/og_thumb.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:type" content="website" />
<!-- misc -->
<meta http-equiv="Cache-Control" content="private, max-age=0, no-store, no-cache, must-revalidate" />
<meta http-equiv="Expires" content="0" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.ico" />
<link rel="canonical" href="https://shapez.io" />
</head>
<body oncontextmenu="return false" style="background: #393747;"></body>
</html>

362
src/js/application.js Normal file
View File

@@ -0,0 +1,362 @@
import { AnimationFrame } from "./core/animation_frame";
import { performanceNow } from "./core/builtins";
import { GameState } from "./core/game_state";
import { GLOBAL_APP, setGlobalApp } from "./core/globals";
import { InputDistributor } from "./core/input_distributor";
import { StateManager } from "./core/state_manager";
import { getPlatformName, waitNextFrame } from "./core/utils";
import { SavegameManager } from "./savegame/savegame_manager";
import { AdProviderInterface } from "./platform/ad_provider";
import { NoAdProvider } from "./platform/ad_providers/no_ad_provider";
import { SoundImplBrowser } from "./platform/browser/sound";
import { StorageImplBrowser } from "./platform/browser/storage";
import { PlatformWrapperImplBrowser } from "./platform/browser/wrapper";
import { SoundInterface } from "./platform/sound";
import { StorageInterface } from "./platform/storage";
import { PlatformWrapperInterface } from "./platform/wrapper";
import { ApplicationSettings } from "./profile/application_settings";
import { Vector } from "./core/vector";
import { createLogger, logSection } from "./core/logging";
import { TrackedState } from "./core/tracked_state";
import { IS_MOBILE } from "./core/config";
import { BackgroundResourcesLoader } from "./core/background_resources_loader";
import { PreloadState } from "./states/preload";
import { MainMenuState } from "./states/main_menu";
import { InGameState } from "./states/ingame";
import { AnalyticsInterface } from "./platform/analytics";
import { GoogleAnalyticsImpl } from "./platform/browser/google_analytics";
import { Loader } from "./core/loader";
import { GameAnalyticsInterface } from "./platform/game_analytics";
import { GameAnalyticsDotCom } from "./platform/browser/game_analytics";
const logger = createLogger("application");
// Set the name of the hidden property and the change event for visibility
let pageHiddenPropName, pageVisibilityEventName;
if (typeof document.hidden !== "undefined") {
// Opera 12.10 and Firefox 18 and later support
pageHiddenPropName = "hidden";
pageVisibilityEventName = "visibilitychange";
// @ts-ignore
} else if (typeof document.msHidden !== "undefined") {
pageHiddenPropName = "msHidden";
pageVisibilityEventName = "msvisibilitychange";
// @ts-ignore
} else if (typeof document.webkitHidden !== "undefined") {
pageHiddenPropName = "webkitHidden";
pageVisibilityEventName = "webkitvisibilitychange";
}
export class Application {
constructor() {
assert(!GLOBAL_APP, "Tried to construct application twice");
logger.log("Creating application, platform =", getPlatformName());
setGlobalApp(this);
this.unloaded = false;
// Global stuff
this.settings = new ApplicationSettings(this);
this.ticker = new AnimationFrame();
this.stateMgr = new StateManager(this);
this.savegameMgr = new SavegameManager(this);
this.inputMgr = new InputDistributor(this);
this.backgroundResourceLoader = new BackgroundResourcesLoader(this);
// Platform dependent stuff
/** @type {StorageInterface} */
this.storage = null;
/** @type {SoundInterface} */
this.sound = null;
/** @type {PlatformWrapperInterface} */
this.platformWrapper = null;
/** @type {AdProviderInterface} */
this.adProvider = null;
/** @type {AnalyticsInterface} */
this.analytics = null;
/** @type {GameAnalyticsInterface} */
this.gameAnalytics = null;
this.initPlatformDependentInstances();
// Track if the window is focused (only relevant for browser)
this.focused = true;
// Track if the window is visible
this.pageVisible = true;
// Track if the app is paused (cordova)
this.applicationPaused = false;
/** @type {TypedTrackedState<boolean>} */
this.trackedIsRenderable = new TrackedState(this.onAppRenderableStateChanged, this);
// Dimensions
this.screenWidth = 0;
this.screenHeight = 0;
// Store the timestamp where we last checked for a screen resize, since orientationchange is unreliable with cordova
this.lastResizeCheck = null;
// Store the mouse position, or null if not available
/** @type {Vector|null} */
this.mousePosition = null;
}
/**
* Initializes all platform instances
*/
initPlatformDependentInstances() {
logger.log("Creating platform dependent instances");
// Start with empty ad provider
this.adProvider = new NoAdProvider(this);
this.storage = new StorageImplBrowser(this);
this.sound = new SoundImplBrowser(this);
this.platformWrapper = new PlatformWrapperImplBrowser(this);
this.analytics = new GoogleAnalyticsImpl(this);
this.gameAnalytics = new GameAnalyticsDotCom(this);
}
/**
* Registers all game states
*/
registerStates() {
/** @type {Array<typeof GameState>} */
const states = [PreloadState, MainMenuState, InGameState];
for (let i = 0; i < states.length; ++i) {
this.stateMgr.register(states[i]);
}
}
/**
* Registers all event listeners
*/
registerEventListeners() {
window.addEventListener("focus", this.onFocus.bind(this));
window.addEventListener("blur", this.onBlur.bind(this));
window.addEventListener("resize", () => this.checkResize(), true);
window.addEventListener("orientationchange", () => this.checkResize(), true);
if (!G_IS_MOBILE_APP && !IS_MOBILE) {
window.addEventListener("mousemove", this.handleMousemove.bind(this));
}
// Unload events
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this), true);
window.addEventListener("unload", this.onUnload.bind(this), true);
document.addEventListener(pageVisibilityEventName, this.handleVisibilityChange.bind(this), false);
// Track touches so we can update the focus appropriately
document.addEventListener("touchstart", this.updateFocusAfterUserInteraction.bind(this), true);
document.addEventListener("touchend", this.updateFocusAfterUserInteraction.bind(this), true);
}
/**
* Checks the focus after a touch
* @param {TouchEvent} event
*/
updateFocusAfterUserInteraction(event) {
const target = /** @type {HTMLElement} */ (event.target);
if (!target || !target.tagName) {
// Safety check
logger.warn("Invalid touchstart/touchend event:", event);
return;
}
// When clicking an element which is not the currently focused one, defocus it
if (target !== document.activeElement) {
// @ts-ignore
if (document.activeElement.blur) {
// @ts-ignore
document.activeElement.blur();
}
}
// If we click an input field, focus it now
if (target.tagName.toLowerCase() === "input") {
// We *really* need the focus
waitNextFrame().then(() => target.focus());
}
}
/**
* Handles a page visibility change event
* @param {Event} event
*/
handleVisibilityChange(event) {
const pageVisible = !document[pageHiddenPropName];
if (pageVisible !== this.pageVisible) {
this.pageVisible = pageVisible;
logger.log("Visibility changed:", this.pageVisible);
this.trackedIsRenderable.set(this.isRenderable());
}
}
/**
* Handles a mouse move event
* @param {MouseEvent} event
*/
handleMousemove(event) {
this.mousePosition = new Vector(event.clientX, event.clientY);
}
/**
* Internal on focus handler
*/
onFocus() {
this.focused = true;
}
/**
* Internal blur handler
*/
onBlur() {
this.focused = false;
}
/**
* Returns if the app is currently visible
*/
isRenderable() {
return !this.applicationPaused && this.pageVisible;
}
onAppRenderableStateChanged(renderable) {
logger.log("Application renderable:", renderable);
if (!renderable) {
this.stateMgr.getCurrentState().onAppPause();
} else {
// Got resume
this.stateMgr.getCurrentState().onAppResume();
this.checkResize();
}
this.sound.onPageRenderableStateChanged(renderable);
}
/**
* Internal unload handler
*/
onUnload(event) {
if (!this.unloaded) {
logSection("UNLOAD HANDLER", "#f77");
this.unloaded = true;
this.stateMgr.getCurrentState().onBeforeExit();
this.deinitialize();
}
}
/**
* Internal before-unload handler
*/
onBeforeUnload(event) {
logSection("BEFORE UNLOAD HANDLER", "#f77");
if (!G_IS_DEV && this.stateMgr.getCurrentState().getHasUnloadConfirmation()) {
if (G_IS_STANDALONE) {
} else {
// Need to show a "Are you sure you want to exit"
event.preventDefault();
event.returnValue = "Are you sure you want to exit?";
}
}
}
/**
* Boots the application
*/
boot() {
this.registerStates();
this.registerEventListeners();
Loader.linkAppAfterBoot(this);
this.stateMgr.moveToState("PreloadState");
// Starting rendering
this.ticker.frameEmitted.add(this.onFrameEmitted, this);
this.ticker.bgFrameEmitted.add(this.onBackgroundFrame, this);
this.ticker.start();
}
/**
* Deinitializes the application
*/
deinitialize() {
return this.sound.deinitialize();
}
/**
* Background frame update callback
* @param {number} dt
*/
onBackgroundFrame(dt) {
if (this.isRenderable()) {
return;
}
this.stateMgr.getCurrentState().onBackgroundTick(dt);
}
/**
* Frame update callback
* @param {number} dt
*/
onFrameEmitted(dt) {
if (!this.isRenderable()) {
return;
}
const time = performanceNow();
// Periodically check for resizes, this is expensive (takes 2-3ms so only do it once in a while!)
if (!this.lastResizeCheck || time - this.lastResizeCheck > 1000) {
this.checkResize();
this.lastResizeCheck = time;
}
this.stateMgr.getCurrentState().onRender(dt);
}
/**
* Checks if the app resized. Only does this once in a while
* @param {boolean} forceUpdate Forced update of the dimensions
*/
checkResize(forceUpdate = false) {
const w = window.innerWidth;
const h = window.innerHeight;
if (this.screenWidth !== w || this.screenHeight !== h || forceUpdate) {
this.screenWidth = w;
this.screenHeight = h;
this.stateMgr.getCurrentState().onResized(this.screenWidth, this.screenHeight);
const scale = this.getEffectiveUiScale();
waitNextFrame().then(() => document.documentElement.style.setProperty("--ui-scale", scale));
window.focus();
}
}
/**
* Returns the effective ui sclae
*/
getEffectiveUiScale() {
return this.platformWrapper.getUiScale() * this.settings.getInterfaceScaleValue();
}
/**
* Callback after ui scale has changed
*/
updateAfterUiScaleChanged() {
this.checkResize(true);
}
}

View File

@@ -0,0 +1,71 @@
import { Signal } from "./signal";
// @ts-ignore
import BackgroundAnimationFrameEmitterWorker from "../webworkers/background_animation_frame_emittter.worker";
import { createLogger } from "./logging";
import { performanceNow } from "./builtins";
const logger = createLogger("animation_frame");
const maxDtMs = 1000;
const resetDtMs = 16;
export class AnimationFrame {
constructor() {
this.frameEmitted = new Signal();
this.bgFrameEmitted = new Signal();
this.lastTime = null;
this.bgLastTime = null;
this.boundMethod = this.handleAnimationFrame.bind(this);
/** @type {Worker} */
this.backgroundWorker = new BackgroundAnimationFrameEmitterWorker();
this.backgroundWorker.addEventListener("error", err => {
logger.error("Error in background fps worker:", err);
});
this.backgroundWorker.addEventListener("message", this.handleBackgroundTick.bind(this));
}
/**
*
* @param {MessageEvent} event
*/
handleBackgroundTick(event) {
const time = performanceNow();
if (!this.bgLastTime) {
// First update, first delta is always 16ms
this.bgFrameEmitted.dispatch(1000 / 60);
} else {
let dt = time - this.bgLastTime;
if (dt > maxDtMs) {
dt = resetDtMs;
}
this.bgFrameEmitted.dispatch(dt);
}
this.bgLastTime = time;
}
start() {
assertAlways(window.requestAnimationFrame, "requestAnimationFrame is not supported!");
this.handleAnimationFrame();
}
handleAnimationFrame(time) {
if (!this.lastTime) {
// First update, first delta is always 16ms
this.frameEmitted.dispatch(1000 / 60);
} else {
let dt = time - this.lastTime;
if (dt > maxDtMs) {
// warn(this, "Clamping", dt, "to", resetDtMs);
dt = resetDtMs;
}
this.frameEmitted.dispatch(dt);
}
this.lastTime = time;
window.requestAnimationFrame(this.boundMethod);
}
}

26
src/js/core/assert.js Normal file
View File

@@ -0,0 +1,26 @@
import { createLogger } from "./logging";
const logger = createLogger("assert");
let assertionErrorShown = false;
function initAssert() {
/**
* Expects a given condition to be true
* @param {Boolean} condition
* @param {...String} failureMessage
*/
// @ts-ignore
window.assert = function (condition, ...failureMessage) {
if (!condition) {
logger.error("assertion failed:", ...failureMessage);
if (!assertionErrorShown) {
// alert("Assertion failed (the game will try to continue to run): \n\n" + failureMessage);
assertionErrorShown = true;
}
throw new Error("AssertionError: " + failureMessage.join(" "));
}
};
}
initAssert();

View File

@@ -0,0 +1,143 @@
// @ts-ignore
import CompressionWorker from "../webworkers/compression.worker";
import { createLogger } from "./logging";
import { compressX64 } from "./lzstring";
import { performanceNow, JSON_stringify } from "./builtins";
const logger = createLogger("async_compression");
export let compressionPrefix = String.fromCodePoint(1);
function checkCryptPrefix(prefix) {
try {
window.localStorage.setItem("prefix_test", prefix);
window.localStorage.removeItem("prefix_test");
return true;
} catch (ex) {
logger.warn("Prefix '" + prefix + "' not available");
return false;
}
}
if (!checkCryptPrefix(compressionPrefix)) {
logger.warn("Switching to basic prefix");
compressionPrefix = " ";
if (!checkCryptPrefix(compressionPrefix)) {
logger.warn("Prefix not available, ls seems to be unavailable");
}
}
/**
* @typedef {{
* errorHandler: function(any) : void,
* resolver: function(any) : void,
* startTime: number
* }} JobEntry
*/
class AsynCompression {
constructor() {
/** @type {Worker} */
this.worker = new CompressionWorker();
this.currentJobId = 1000;
/** @type {Object.<number, JobEntry>} */
this.currentJobs = {};
this.worker.addEventListener("message", event => {
const { jobId, result } = event.data;
const jobData = this.currentJobs[jobId];
if (!jobData) {
logger.error("Failed to resolve job result, job id", jobId, "is not known");
return;
}
const duration = performanceNow() - jobData.startTime;
// log(this, "Got response from worker within", duration.toFixed(2), "ms");
const resolver = jobData.resolver;
delete this.currentJobs[jobId];
resolver(result);
});
this.worker.addEventListener("error", err => {
logger.error("Got error from webworker:", err, "aborting all jobs");
const failureCalls = [];
for (const jobId in this.currentJobs) {
failureCalls.push(this.currentJobs[jobId].errorHandler);
}
this.currentJobs = {};
for (let i = 0; i < failureCalls.length; ++i) {
failureCalls[i](err);
}
});
}
/**
* Compresses file
* @param {string} text
*/
compressFileAsync(text) {
return this.internalQueueJob("compressFile", {
text,
compressionPrefix,
});
}
/**
* Compresses regulary
* @param {string} text
*/
compressX64Async(text) {
if (text.length < 1024) {
// Ok so this is not worth it
return Promise.resolve(compressX64(text));
}
return this.internalQueueJob("compressX64", text);
}
/**
* Compresses with checksum
* @param {any} obj
*/
compressWithChecksum(obj) {
const stringified = JSON_stringify(obj);
return this.internalQueueJob("compressWithChecksum", stringified);
}
/**
* Compresses with checksum
* @param {any} data The packets data
* @param {number} packetId The numeric packet id
*/
compressPacket(data, packetId) {
return this.internalQueueJob("compressPacket", {
data,
packetId,
});
}
/**
* Queues a new job
* @param {string} job
* @param {any} data
* @returns {Promise<any>}
*/
internalQueueJob(job, data) {
const jobId = ++this.currentJobId;
return new Promise((resolve, reject) => {
const errorHandler = err => {
logger.error("Failed to compress job", jobId, ":", err);
reject(err);
};
this.currentJobs[jobId] = {
errorHandler,
resolver: resolve,
startTime: performanceNow(),
};
this.worker.postMessage({ jobId, job, data });
});
}
}
export const asyncCompressor = new AsynCompression();

View File

@@ -0,0 +1,38 @@
/**
* @typedef {{
* frame: { x: number, y: number, w: number, h: number },
* rotated: false,
* spriteSourceSize: { x: number, y: number, w: number, h: number },
* sourceSize: { w: number, h: number},
* trimmed: true
* }} SpriteDefinition
*/
export class AtlasDefinition {
constructor(sourceData) {
this.sourceFileName = sourceData.meta.image;
this.meta = sourceData.meta;
/** @type {Object.<string, SpriteDefinition>} */
this.sourceData = sourceData.frames;
}
getFullSourcePath() {
return this.sourceFileName;
}
}
// @ts-ignore
export const atlasFiles = require
.context("../../../res_built/atlas/", false, /.*\.json/i)
.keys()
.map(f => f.replace(/^\.\//gi, ""))
.map(f => require("../../../res_built/atlas/" + f))
.map(data => new AtlasDefinition(data));
// export const atlasDefinitions = {
// qualityPreload: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_preload") >= 0),
// qualityLow: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_low") >= 0),
// qualityMedium: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_medium") >= 0),
// qualityHigh: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_high") >= 0),
// };

View File

@@ -0,0 +1,216 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
import { Loader } from "./loader";
import { createLogger } from "./logging";
import { Signal } from "./signal";
import { SOUNDS, MUSIC } from "../platform/sound";
import { AtlasDefinition, atlasFiles } from "./atlas_definitions";
const logger = createLogger("background_loader");
const essentialMainMenuSprites = ["logo.png", ...G_ALL_UI_IMAGES.filter(src => src.startsWith("ui/"))];
const essentialMainMenuSounds = [
SOUNDS.uiClick,
SOUNDS.uiError,
SOUNDS.dialogError,
SOUNDS.dialogOk,
SOUNDS.swishShow,
SOUNDS.swishHide,
];
const essentialBareGameAtlases = atlasFiles;
const essentialBareGameSprites = G_ALL_UI_IMAGES;
const essentialBareGameSounds = [MUSIC.gameBg];
const additionalGameSprites = [];
const additionalGameSounds = [];
for (const key in SOUNDS) {
additionalGameSounds.push(SOUNDS[key]);
}
for (const key in MUSIC) {
additionalGameSounds.push(MUSIC[key]);
}
export class BackgroundResourcesLoader {
/**
*
* @param {Application} app
*/
constructor(app) {
this.app = app;
this.registerReady = false;
this.mainMenuReady = false;
this.bareGameReady = false;
this.additionalReady = false;
this.signalMainMenuLoaded = new Signal();
this.signalBareGameLoaded = new Signal();
this.signalAdditionalLoaded = new Signal();
this.numAssetsLoaded = 0;
this.numAssetsToLoadTotal = 0;
// Avoid loading stuff twice
this.spritesLoaded = [];
this.soundsLoaded = [];
}
getNumAssetsLoaded() {
return this.numAssetsLoaded;
}
getNumAssetsTotal() {
return this.numAssetsToLoadTotal;
}
getPromiseForMainMenu() {
if (this.mainMenuReady) {
return Promise.resolve();
}
return new Promise(resolve => {
this.signalMainMenuLoaded.add(resolve);
});
}
getPromiseForBareGame() {
if (this.bareGameReady) {
return Promise.resolve();
}
return new Promise(resolve => {
this.signalBareGameLoaded.add(resolve);
});
}
startLoading() {
this.internalStartLoadingEssentialsForMainMenu();
}
internalStartLoadingEssentialsForMainMenu() {
logger.log("⏰ Start load: main menu");
this.internalLoadSpritesAndSounds(essentialMainMenuSprites, essentialMainMenuSounds)
.catch(err => {
logger.warn("⏰ Failed to load essentials for main menu:", err);
})
.then(() => {
logger.log("⏰ Finish load: main menu");
this.mainMenuReady = true;
this.signalMainMenuLoaded.dispatch();
this.internalStartLoadingEssentialsForBareGame();
});
}
internalStartLoadingEssentialsForBareGame() {
logger.log("⏰ Start load: bare game");
this.internalLoadSpritesAndSounds(
essentialBareGameSprites,
essentialBareGameSounds,
essentialBareGameAtlases
)
.catch(err => {
logger.warn("⏰ Failed to load essentials for bare game:", err);
})
.then(() => {
logger.log("⏰ Finish load: bare game");
Loader.createAtlasLinks();
this.bareGameReady = true;
this.signalBareGameLoaded.dispatch();
this.internalStartLoadingAdditionalGameAssets();
});
}
internalStartLoadingAdditionalGameAssets() {
const additionalAtlases = [];
logger.log("⏰ Start load: additional assets (", additionalAtlases.length, "images)");
this.internalLoadSpritesAndSounds(additionalGameSprites, additionalGameSounds, additionalAtlases)
.catch(err => {
logger.warn("⏰ Failed to load additional assets:", err);
})
.then(() => {
logger.log("⏰ Finish load: additional assets");
this.additionalReady = true;
this.signalAdditionalLoaded.dispatch();
});
}
/**
* @param {Array<string>} sprites
* @param {Array<string>} sounds
* @param {Array<AtlasDefinition>} atlases
* @returns {Promise<void>}
*/
internalLoadSpritesAndSounds(sprites, sounds, atlases = []) {
this.numAssetsToLoadTotal = sprites.length + sounds.length + atlases.length;
this.numAssetsLoaded = 0;
let promises = [];
for (let i = 0; i < sounds.length; ++i) {
if (this.soundsLoaded.indexOf(sounds[i]) >= 0) {
// Already loaded
continue;
}
this.soundsLoaded.push(sounds[i]);
promises.push(
this.app.sound
.loadSound(sounds[i])
.catch(err => {
logger.warn("Failed to load sound:", sounds[i]);
})
.then(() => {
this.numAssetsLoaded++;
})
);
}
for (let i = 0; i < sprites.length; ++i) {
if (this.spritesLoaded.indexOf(sprites[i]) >= 0) {
// Already loaded
continue;
}
this.spritesLoaded.push(sprites[i]);
promises.push(
Loader.preloadCSSSprite(sprites[i])
.catch(err => {
logger.warn("Failed to load css sprite:", sprites[i]);
})
.then(() => {
this.numAssetsLoaded++;
})
);
}
for (let i = 0; i < atlases.length; ++i) {
const atlas = atlases[i];
promises.push(
Loader.preloadAtlas(atlas)
.catch(err => {
logger.warn("Failed to load atlas:", atlas.sourceFileName);
})
.then(() => {
this.numAssetsLoaded++;
})
);
}
return (
Promise.all(promises)
// // Remove some pressure by waiting a bit
// .then(() => {
// return new Promise(resolve => {
// setTimeout(resolve, 200);
// });
// })
.then(() => {
this.numAssetsToLoadTotal = 0;
this.numAssetsLoaded = 0;
})
);
}
}

View File

@@ -0,0 +1,146 @@
import { GameRoot } from "../game/root";
import {
makeOffscreenBuffer,
freeCanvas,
getBufferVramUsageBytes,
getBufferStats,
clearBufferBacklog,
} from "./buffer_utils";
import { createLogger } from "./logging";
import { round2Digits, round1Digit } from "./utils";
/**
* @typedef {{
* canvas: HTMLCanvasElement,
* context: CanvasRenderingContext2D,
* lastUse: number,
* }} CacheEntry
*/
const logger = createLogger("buffers");
const bufferGcDurationSeconds = 3;
export class BufferMaintainer {
/**
* @param {GameRoot} root
*/
constructor(root) {
this.root = root;
/** @type {Map<string, Map<string, CacheEntry>>} */
this.cache = new Map();
this.iterationIndex = 1;
this.lastIteration = 0;
}
/**
* Goes to the next buffer iteration, clearing all buffers which were not used
* for a few iterations
*/
garbargeCollect() {
let totalKeys = 0;
let deletedKeys = 0;
const minIteration = this.iterationIndex;
this.cache.forEach((subCache, key) => {
let unusedSubKeys = [];
// Filter sub cache
subCache.forEach((cacheEntry, subKey) => {
if (cacheEntry.lastUse < minIteration) {
unusedSubKeys.push(subKey);
freeCanvas(cacheEntry.canvas);
++deletedKeys;
} else {
++totalKeys;
}
});
// Delete unused sub keys
for (let i = 0; i < unusedSubKeys.length; ++i) {
subCache.delete(unusedSubKeys[i]);
}
});
// Make sure our backlog never gets too big
clearBufferBacklog();
const bufferStats = getBufferStats();
const mbUsed = round1Digit(bufferStats.vramUsage / (1024 * 1024));
logger.log(
"GC: Remove",
(deletedKeys + "").padStart(4),
", Remain",
(totalKeys + "").padStart(4),
"(",
(bufferStats.bufferCount + "").padStart(4),
"total",
")",
"(",
(bufferStats.backlog + "").padStart(4),
"backlog",
")",
"VRAM:",
mbUsed,
"MB"
);
++this.iterationIndex;
}
update() {
const now = this.root.time.realtimeNow();
if (now - this.lastIteration > bufferGcDurationSeconds) {
this.lastIteration = now;
this.garbargeCollect();
}
}
/**
*
* @param {string} key
* @param {string} subKey
* @param {function(HTMLCanvasElement, CanvasRenderingContext2D, number, number, number, object?) : void} redrawMethod
* @param {object=} additionalParams
* @returns {HTMLCanvasElement}
*
*/
getForKey(key, subKey, w, h, dpi, redrawMethod, additionalParams) {
// First, create parent key
let parent = this.cache.get(key);
if (!parent) {
parent = new Map();
this.cache.set(key, parent);
}
// Now search for sub key
const cacheHit = parent.get(subKey);
if (cacheHit) {
cacheHit.lastUse = this.iterationIndex;
return cacheHit.canvas;
}
// Need to generate new buffer
const effectiveWidth = w * dpi;
const effectiveHeight = h * dpi;
const [canvas, context] = makeOffscreenBuffer(effectiveWidth, effectiveHeight, {
reusable: true,
label: "buffer-" + key + "/" + subKey,
smooth: true,
});
redrawMethod(canvas, context, w, h, dpi, additionalParams);
parent.set(subKey, {
canvas,
context,
lastUse: this.iterationIndex,
});
return canvas;
}
}

201
src/js/core/buffer_utils.js Normal file
View File

@@ -0,0 +1,201 @@
import { globalConfig } from "./config";
import { Math_max, Math_floor, Math_abs } from "./builtins";
import { fastArrayDelete } from "./utils";
import { createLogger } from "./logging";
const logger = createLogger("buffer_utils");
/**
* Enables images smoothing on a context
* @param {CanvasRenderingContext2D} context
*/
export function enableImageSmoothing(context) {
context.imageSmoothingEnabled = true;
context.webkitImageSmoothingEnabled = true;
// @ts-ignore
context.imageSmoothingQuality = globalConfig.smoothing.quality;
}
/**
* Disables image smoothing on a context
* @param {CanvasRenderingContext2D} context
*/
export function disableImageSmoothing(context) {
context.imageSmoothingEnabled = false;
context.webkitImageSmoothingEnabled = false;
}
const registeredCanvas = [];
const freeCanvasList = [];
let vramUsage = 0;
let bufferCount = 0;
/**
*
* @param {HTMLCanvasElement} canvas
*/
export function getBufferVramUsageBytes(canvas) {
return canvas.width * canvas.height * 4;
}
/**
* Returns stats on the allocated buffers
*/
export function getBufferStats() {
return {
vramUsage,
bufferCount,
backlog: freeCanvasList.length,
};
}
export function clearBufferBacklog() {
while (freeCanvasList.length > 50) {
freeCanvasList.pop();
}
}
/**
* Creates a new offscreen buffer
* @param {Number} w
* @param {Number} h
* @returns {[HTMLCanvasElement, CanvasRenderingContext2D]}
*/
export function makeOffscreenBuffer(w, h, { smooth = true, reusable = true, label = "buffer" }) {
assert(w > 0 && h > 0, "W or H < 0");
if (w % 1 !== 0 || h % 1 !== 0) {
// console.warn("Subpixel offscreen buffer size:", w, h);
}
if (w < 1 || h < 1) {
logger.error("Offscreen buffer size < 0:", w, "x", h);
w = Math_max(1, w);
h = Math_max(1, h);
}
const recommendedSize = 1024 * 1024;
if (w * h > recommendedSize) {
logger.warn("Creating huge buffer:", w, "x", h, "with label", label);
}
w = Math_floor(w);
h = Math_floor(h);
let canvas = null;
let context = null;
let bestMatchingOne = null;
let bestMatchingPixelsDiff = 1e50;
const currentPixels = w * h;
// Ok, search in cache first
for (let i = 0; i < freeCanvasList.length; ++i) {
const { canvas: useableCanvas, context: useableContext } = freeCanvasList[i];
if (useableCanvas.width === w && useableCanvas.height === h) {
// Ok we found one
canvas = useableCanvas;
context = useableContext;
fastArrayDelete(freeCanvasList, i);
break;
}
const otherPixels = useableCanvas.width * useableCanvas.height;
const diff = Math_abs(otherPixels - currentPixels);
if (diff < bestMatchingPixelsDiff) {
bestMatchingPixelsDiff = diff;
bestMatchingOne = {
canvas: useableCanvas,
context: useableContext,
index: i,
};
}
}
// Ok none matching, reuse one though
if (!canvas && bestMatchingOne) {
canvas = bestMatchingOne.canvas;
context = bestMatchingOne.context;
canvas.width = w;
canvas.height = h;
fastArrayDelete(freeCanvasList, bestMatchingOne.index);
}
// Reset context
if (context) {
// Restore past state
context.restore();
context.save();
context.clearRect(0, 0, canvas.width, canvas.height);
delete canvas.style.width;
delete canvas.style.height;
}
// None found , create new one
if (!canvas) {
canvas = document.createElement("canvas");
context = canvas.getContext("2d" /*, { alpha } */);
canvas.width = w;
canvas.height = h;
// Initial state
context.save();
}
canvas.label = label;
if (smooth) {
enableImageSmoothing(context);
} else {
disableImageSmoothing(context);
}
if (reusable) {
registerCanvas(canvas, context);
}
return [canvas, context];
}
/**
* Frees a canvas
* @param {HTMLCanvasElement} canvas
*/
export function registerCanvas(canvas, context) {
registeredCanvas.push({ canvas, context });
bufferCount += 1;
vramUsage += getBufferVramUsageBytes(canvas);
}
/**
* Frees a canvas
* @param {HTMLCanvasElement} canvas
*/
export function freeCanvas(canvas) {
assert(canvas, "Canvas is empty");
let index = -1;
let data = null;
for (let i = 0; i < registeredCanvas.length; ++i) {
if (registeredCanvas[i].canvas === canvas) {
index = i;
data = registeredCanvas[i];
break;
}
}
if (index < 0) {
logger.error("Tried to free unregistered canvas of size", canvas.width, canvas.height);
return;
}
fastArrayDelete(registeredCanvas, index);
freeCanvasList.push(data);
bufferCount -= 1;
vramUsage -= getBufferVramUsageBytes(canvas);
}

34
src/js/core/builtins.js Normal file
View File

@@ -0,0 +1,34 @@
// Store the original version of all builtins to prevent modification
export const JSON_stringify = JSON.stringify.bind(JSON);
export const JSON_parse = JSON.parse.bind(JSON);
export function Math_radians(degrees) {
return (degrees * Math_PI) / 180.0;
}
export function Math_degrees(radians) {
return (radians * 180.0) / Math_PI;
}
export const performanceNow = performance.now.bind(performance);
export const Math_abs = Math.abs.bind(Math);
export const Math_ceil = Math.ceil.bind(Math);
export const Math_floor = Math.floor.bind(Math);
export const Math_round = Math.round.bind(Math);
export const Math_sign = Math.sign.bind(Math);
export const Math_sqrt = Math.sqrt.bind(Math);
export const Math_min = Math.min.bind(Math);
export const Math_max = Math.max.bind(Math);
export const Math_sin = Math.sin.bind(Math);
export const Math_cos = Math.cos.bind(Math);
export const Math_tan = Math.tan.bind(Math);
export const Math_hypot = Math.hypot.bind(Math);
export const Math_atan2 = Math.atan2.bind(Math);
export const Math_pow = Math.pow.bind(Math);
export const Math_random = Math.random.bind(Math);
export const Math_exp = Math.exp.bind(Math);
export const Math_log10 = Math.log10.bind(Math);
export const Math_PI = 3.1415926;

10
src/js/core/cachebust.js Normal file
View File

@@ -0,0 +1,10 @@
/**
* Generates a cachebuster string. This only modifies the path in the browser version
* @param {string} path
*/
export function cachebust(path) {
if (G_IS_BROWSER && !G_IS_STANDALONE && !G_IS_DEV) {
return "/v/" + G_BUILD_COMMIT_HASH + "/" + path;
}
return path;
}

View File

@@ -0,0 +1,431 @@
import { performanceNow } from "../core/builtins";
import { createLogger } from "../core/logging";
import { Signal } from "../core/signal";
import { fastArrayDelete, fastArrayDeleteValueIfContained } from "./utils";
import { Vector } from "./vector";
import { IS_MOBILE } from "./config";
const logger = createLogger("click_detector");
export const MAX_MOVE_DISTANCE_PX = IS_MOBILE ? 20 : 40;
// For debugging
const registerClickDetectors = G_IS_DEV && true;
if (registerClickDetectors) {
/** @type {Array<ClickDetector>} */
window.activeClickDetectors = [];
}
// Store active click detectors so we can cancel them
/** @type {Array<ClickDetector>} */
const ongoingClickDetectors = [];
// Store when the last touch event was registered, to avoid accepting a touch *and* a click event
export let clickDetectorGlobals = {
lastTouchTime: -1000,
};
/**
* Click detector creation payload typehints
* @typedef {{
* consumeEvents?: boolean,
* preventDefault?: boolean,
* applyCssClass?: string,
* captureTouchmove?: boolean,
* targetOnly?: boolean,
* maxDistance?: number,
* clickSound?: string,
* }} ClickDetectorConstructorArgs
*/
// Detects clicks
export class ClickDetector {
/**
*
* @param {Element} element
* @param {object} param1
* @param {boolean=} param1.consumeEvents Whether to call stopPropagation
* (Useful for nested elements where the parent has a click handler as wel)
* @param {boolean=} param1.preventDefault Whether to call preventDefault (Usually makes the handler faster)
* @param {string=} param1.applyCssClass The css class to add while the element is pressed
* @param {boolean=} param1.captureTouchmove Whether to capture touchmove events as well
* @param {boolean=} param1.targetOnly Whether to also accept clicks on child elements (e.target !== element)
* @param {number=} param1.maxDistance The maximum distance in pixels to accept clicks
* @param {string=} param1.clickSound Sound key to play on touchdown
*/
constructor(
element,
{
consumeEvents = false,
preventDefault = true,
applyCssClass = "pressed",
captureTouchmove = false,
targetOnly = false,
maxDistance = MAX_MOVE_DISTANCE_PX,
clickSound = null,
}
) {
assert(element, "No element given!");
this.clickDownPosition = null;
this.consumeEvents = consumeEvents;
this.preventDefault = preventDefault;
this.applyCssClass = applyCssClass;
this.captureTouchmove = captureTouchmove;
this.targetOnly = targetOnly;
this.clickSound = clickSound;
this.maxDistance = maxDistance;
// Signals
this.click = new Signal();
this.rightClick = new Signal();
this.touchstart = new Signal();
this.touchmove = new Signal();
this.touchend = new Signal();
this.touchcancel = new Signal();
// Simple signals which just receive the touch position
this.touchstartSimple = new Signal();
this.touchmoveSimple = new Signal();
this.touchendSimple = new Signal();
// Store time of touch start
this.clickStartTime = null;
// A click can be cancelled if another detector registers a click
this.cancelled = false;
this.internalBindTo(/** @type {HTMLElement} */ (element));
}
/**
* Cleans up all event listeners of this detector
*/
cleanup() {
if (this.element) {
if (registerClickDetectors) {
const index = window.activeClickDetectors.indexOf(this);
if (index < 0) {
logger.error("Click detector cleanup but is not active");
} else {
window.activeClickDetectors.splice(index, 1);
}
}
const options = this.internalGetEventListenerOptions();
this.element.removeEventListener("touchstart", this.handlerTouchStart, options);
this.element.removeEventListener("touchend", this.handlerTouchEnd, options);
this.element.removeEventListener("touchcancel", this.handlerTouchCancel, options);
this.element.removeEventListener("mouseup", this.handlerTouchStart, options);
this.element.removeEventListener("mousedown", this.handlerTouchEnd, options);
this.element.removeEventListener("mouseout", this.handlerTouchCancel, options);
if (this.captureTouchmove) {
this.element.removeEventListener("touchmove", this.handlerTouchMove, options);
this.element.removeEventListener("mousemove", this.handlerTouchMove, options);
}
this.click.removeAll();
this.touchstart.removeAll();
this.touchmove.removeAll();
this.touchend.removeAll();
this.touchcancel.removeAll();
// TODO: Remove pointer captures
this.element = null;
}
}
// INTERNAL METHODS
/**
* Internal method to get the options to pass to an event listener
*/
internalGetEventListenerOptions() {
return {
capture: this.consumeEvents,
passive: !this.preventDefault,
};
}
/**
* Binds the click detector to an element
* @param {HTMLElement} element
*/
internalBindTo(element) {
const options = this.internalGetEventListenerOptions();
this.handlerTouchStart = this.internalOnPointerDown.bind(this);
this.handlerTouchEnd = this.internalOnPointerEnd.bind(this);
this.handlerTouchMove = this.internalOnPointerMove.bind(this);
this.handlerTouchCancel = this.internalOnTouchCancel.bind(this);
element.addEventListener("touchstart", this.handlerTouchStart, options);
element.addEventListener("touchend", this.handlerTouchEnd, options);
element.addEventListener("touchcancel", this.handlerTouchCancel, options);
element.addEventListener("mousedown", this.handlerTouchStart, options);
element.addEventListener("mouseup", this.handlerTouchEnd, options);
element.addEventListener("mouseout", this.handlerTouchCancel, options);
if (this.captureTouchmove) {
element.addEventListener("touchmove", this.handlerTouchMove, options);
element.addEventListener("mousemove", this.handlerTouchMove, options);
}
if (registerClickDetectors) {
window.activeClickDetectors.push(this);
}
this.element = element;
}
/**
* Returns if the bound element is currently in the DOM.
*/
internalIsDomElementAttached() {
return this.element && document.documentElement.contains(this.element);
}
/**
* Checks if the given event is relevant for this detector
* @param {TouchEvent|MouseEvent} event
*/
internalEventPreHandler(event, expectedRemainingTouches = 1) {
if (!this.element) {
// Already cleaned up
return false;
}
if (this.targetOnly && event.target !== this.element) {
// Clicked a child element
return false;
}
// Stop any propagation and defaults if configured
if (this.consumeEvents && event.cancelable) {
event.stopPropagation();
}
if (this.preventDefault && event.cancelable) {
event.preventDefault();
}
if (window.TouchEvent && event instanceof TouchEvent) {
clickDetectorGlobals.lastTouchTime = performanceNow();
// console.log("Got touches", event.targetTouches.length, "vs", expectedRemainingTouches);
if (event.targetTouches.length !== expectedRemainingTouches) {
return false;
}
}
if (event instanceof MouseEvent) {
if (performanceNow() - clickDetectorGlobals.lastTouchTime < 1000.0) {
return false;
}
}
return true;
}
/**
* Extracts the mous position from an event
* @param {TouchEvent|MouseEvent} event
* @returns {Vector} The client space position
*/
static extractPointerPosition(event) {
if (window.TouchEvent && event instanceof TouchEvent) {
if (event.changedTouches.length !== 1) {
logger.warn(
"Got unexpected target touches:",
event.targetTouches.length,
"->",
event.targetTouches
);
return new Vector(0, 0);
}
const touch = event.changedTouches[0];
return new Vector(touch.clientX, touch.clientY);
}
if (event instanceof MouseEvent) {
return new Vector(event.clientX, event.clientY);
}
assertAlways(false, "Got unknown event: " + event);
return new Vector(0, 0);
}
/**
* Cacnels all ongoing events on this detector
*/
cancelOngoingEvents() {
if (this.applyCssClass && this.element) {
this.element.classList.remove(this.applyCssClass);
}
this.clickDownPosition = null;
this.clickStartTime = null;
this.cancelled = true;
fastArrayDeleteValueIfContained(ongoingClickDetectors, this);
}
/**
* Internal pointer down handler
* @param {TouchEvent|MouseEvent} event
*/
internalOnPointerDown(event) {
if (!this.internalEventPreHandler(event, 1)) {
return false;
}
const position = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
if (event instanceof MouseEvent) {
const isRightClick = event.which == 3;
if (isRightClick) {
// Ignore right clicks
this.rightClick.dispatch(position, event);
this.cancelled = true;
return;
}
}
if (this.clickDownPosition) {
logger.warn("Ignoring double click");
return false;
}
this.cancelled = false;
this.touchstart.dispatch(event);
// Store where the touch started
this.clickDownPosition = position;
this.clickStartTime = performanceNow();
this.touchstartSimple.dispatch(this.clickDownPosition.x, this.clickDownPosition.y);
// If we are not currently within a click, register it
if (ongoingClickDetectors.indexOf(this) < 0) {
ongoingClickDetectors.push(this);
} else {
logger.warn("Click detector got pointer down of active pointer twice");
}
// If we should apply any classes, do this now
if (this.applyCssClass) {
this.element.classList.add(this.applyCssClass);
}
// If we should play any sound, do this
if (this.clickSound) {
throw new Error("TODO: Play sounds on click");
// GLOBAL_APP.sound.playUiSound(this.clickSound);
}
return false;
}
/**
* Internal pointer move handler
* @param {TouchEvent|MouseEvent} event
*/
internalOnPointerMove(event) {
if (!this.internalEventPreHandler(event, 1)) {
return false;
}
this.touchmove.dispatch(event);
const pos = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
this.touchmoveSimple.dispatch(pos.x, pos.y);
return false;
}
/**
* Internal pointer end handler
* @param {TouchEvent|MouseEvent} event
*/
internalOnPointerEnd(event) {
if (!this.internalEventPreHandler(event, 0)) {
return false;
}
if (this.cancelled) {
// warn(this, "Not dispatching touchend on cancelled listener");
return false;
}
if (event instanceof MouseEvent) {
const isRightClick = event.which == 3;
if (isRightClick) {
return;
}
}
const index = ongoingClickDetectors.indexOf(this);
if (index < 0) {
logger.warn("Got pointer end but click detector is not in pressed state");
} else {
fastArrayDelete(ongoingClickDetectors, index);
}
let dispatchClick = false;
let dispatchClickPos = null;
// Check for correct down position, otherwise must have pinched or so
if (this.clickDownPosition) {
const pos = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
const distance = pos.distance(this.clickDownPosition);
if (distance <= this.maxDistance) {
dispatchClick = true;
dispatchClickPos = pos;
} else {
// console.warn("[ClickDetector] Touch does not count as click: ms=", timeSinceStart, "-> tolerance:", tolerance, "(was", distance, ")");
}
}
this.clickDownPosition = null;
this.clickStartTime = null;
if (this.applyCssClass) {
this.element.classList.remove(this.applyCssClass);
}
// Dispatch in the end to avoid the element getting invalidated
// Also make sure that the element is still in the dom
if (this.internalIsDomElementAttached()) {
this.touchend.dispatch(event);
this.touchendSimple.dispatch();
if (dispatchClick) {
const detectors = ongoingClickDetectors.slice();
for (let i = 0; i < detectors.length; ++i) {
detectors[i].cancelOngoingEvents();
}
this.click.dispatch(dispatchClickPos, event);
}
}
return false;
}
/**
* Internal touch cancel handler
* @param {TouchEvent|MouseEvent} event
*/
internalOnTouchCancel(event) {
if (!this.internalEventPreHandler(event, 0)) {
return false;
}
if (this.cancelled) {
// warn(this, "Not dispatching touchcancel on cancelled listener");
return false;
}
this.cancelOngoingEvents();
this.touchcancel.dispatch(event);
this.touchendSimple.dispatch(event);
return false;
}
}

104
src/js/core/config.js Normal file
View File

@@ -0,0 +1,104 @@
export const IS_DEBUG =
G_IS_DEV &&
typeof window !== "undefined" &&
window.location.port === "3005" &&
(window.location.host.indexOf("localhost:") >= 0 || window.location.host.indexOf("192.168.0.") >= 0) &&
window.location.search.indexOf("nodebug") < 0;
const smoothCanvas = true;
export const globalConfig = {
// Size of a single tile in Pixels.
// NOTICE: Update webpack.production.config too!
tileSize: 32,
halfTileSize: 16,
// Which dpi the assets have
assetsDpi: 192 / 32,
assetsSharpness: 1.2,
shapesSharpness: 1.4,
// [Calculated] physics step size
physicsDeltaMs: 0,
physicsDeltaSeconds: 0,
// Update physics at N fps, independent of rendering
physicsUpdateRate: 60,
// Map
mapChunkSize: 32,
mapChunkPrerenderMinZoom: 0.7,
mapChunkOverviewMinZoom: 0.7,
// Belt speeds
// NOTICE: Update webpack.production.config too!
beltSpeedItemsPerSecond: 1,
itemSpacingOnBelts: 0.63,
minerSpeedItemsPerSecond: 0, // COMPUTED
undergroundBeltMaxTiles: 5,
buildingSpeeds: {
cutter: 1 / 6,
rotater: 1 / 2,
painter: 1 / 3,
mixer: 1 / 2,
stacker: 1 / 5,
},
// Zooming
initialZoom: 1.9,
minZoomLevel: 0.1,
maxZoomLevel: 3,
// Global game speed
gameSpeed: 1,
warmupTimeSecondsFast: 0.1,
warmupTimeSecondsRegular: 1,
smoothing: {
smoothMainCanvas: smoothCanvas && true,
quality: "low", // Low is CRUCIAL for mobile performance!
},
rendering: {},
debug: {
/* dev:start */
fastGameEnter: true,
noArtificialDelays: true,
disableSavegameWrite: false,
showEntityBounds: false,
showAcceptorEjectors: false,
usePlainShapeIds: true,
disableMusic: true,
doNotRenderStatics: false,
disableZoomLimits: false,
showChunkBorders: false,
rewardsInstant: false,
allBuildingsUnlocked: true,
upgradesNoCost: true,
disableUnlockDialog: true,
/* dev:end */
},
// Secret vars
info: {
// Binary file salt
file: "Ec'])@^+*9zMevK3uMV4432x9%iK'=",
// Savegame salt
sgSalt: "}95Q3%8/.837Lqym_BJx%q7)pAHJbF",
},
};
export const IS_MOBILE = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
// Automatic calculations
globalConfig.physicsDeltaMs = 1000.0 / globalConfig.physicsUpdateRate;
globalConfig.physicsDeltaSeconds = 1.0 / globalConfig.physicsUpdateRate;
globalConfig.minerSpeedItemsPerSecond =
globalConfig.beltSpeedItemsPerSecond / globalConfig.itemSpacingOnBelts / 6;

117
src/js/core/dpi_manager.js Normal file
View File

@@ -0,0 +1,117 @@
import { globalConfig } from "../core/config";
import { Math_ceil, Math_floor, Math_round } from "./builtins";
import { round1Digit, round2Digits } from "./utils";
/**
* Returns the current dpi
* @returns {number}
*/
export function getDeviceDPI() {
return window.devicePixelRatio || 1;
}
/**
*
* @param {number} dpi
* @returns {number} Smoothed dpi
*/
export function smoothenDpi(dpi) {
if (dpi < 0.05) {
return 0.05;
} else if (dpi < 0.1) {
return round2Digits(dpi);
} else if (dpi < 1) {
return round1Digit(dpi);
} else {
return round1Digit(Math_round(dpi / 0.5) * 0.5);
}
}
// Initial dpi
// setDPIMultiplicator(1);
/**
* Prepares a context for hihg dpi rendering
* @param {CanvasRenderingContext2D} context
*/
export function prepareHighDPIContext(context, smooth = true) {
const dpi = getDeviceDPI();
context.scale(dpi, dpi);
if (smooth) {
context.imageSmoothingEnabled = true;
context.webkitImageSmoothingEnabled = true;
// @ts-ignore
context.imageSmoothingQuality = globalConfig.smoothing.quality;
} else {
context.imageSmoothingEnabled = false;
context.webkitImageSmoothingEnabled = false;
}
}
/**
* Resizes a high dpi canvas
* @param {HTMLCanvasElement} canvas
* @param {number} w
* @param {number} h
*/
export function resizeHighDPICanvas(canvas, w, h, smooth = true) {
const dpi = getDeviceDPI();
const wNumber = Math_floor(w);
const hNumber = Math_floor(h);
const targetW = Math_floor(wNumber * dpi);
const targetH = Math_floor(hNumber * dpi);
if (targetW !== canvas.width || targetH !== canvas.height) {
// console.log("Resize Canvas from", canvas.width, canvas.height, "to", targetW, targetH)
canvas.width = targetW;
canvas.height = targetH;
canvas.style.width = wNumber + "px";
canvas.style.height = hNumber + "px";
prepareHighDPIContext(canvas.getContext("2d"), smooth);
}
}
/**
* Resizes a canvas
* @param {HTMLCanvasElement} canvas
* @param {number} w
* @param {number} h
*/
export function resizeCanvas(canvas, w, h, setStyle = true) {
const actualW = Math_ceil(w);
const actualH = Math_ceil(h);
if (actualW !== canvas.width || actualH !== canvas.height) {
canvas.width = actualW;
canvas.height = actualH;
if (setStyle) {
canvas.style.width = actualW + "px";
canvas.style.height = actualH + "px";
}
// console.log("Resizing canvas to", actualW, "x", actualH);
}
}
/**
* Resizes a canvas and makes sure its cleared
* @param {HTMLCanvasElement} canvas
* @param {CanvasRenderingContext2D} context
* @param {number} w
* @param {number} h
*/
export function resizeCanvasAndClear(canvas, context, w, h) {
const actualW = Math_ceil(w);
const actualH = Math_ceil(h);
if (actualW !== canvas.width || actualH !== canvas.height) {
canvas.width = actualW;
canvas.height = actualH;
canvas.style.width = actualW + "px";
canvas.style.height = actualH + "px";
// console.log("Resizing canvas to", actualW, "x", actualH);
} else {
context.clearRect(0, 0, actualW, actualH);
}
}

View File

@@ -0,0 +1,25 @@
import { Rectangle } from "./rectangle";
/* typehints:start */
import { GameRoot } from "../game/root";
/* typehints:end */
export class DrawParameters {
constructor({ context, visibleRect, desiredAtlasScale, zoomLevel, root }) {
/** @type {CanvasRenderingContext2D} */
this.context = context;
/** @type {Rectangle} */
this.visibleRect = visibleRect;
/** @type {number} */
this.desiredAtlasScale = desiredAtlasScale;
/** @type {number} */
this.zoomLevel = zoomLevel;
// FIXME: Not really nice
/** @type {GameRoot} */
this.root = root;
}
}

321
src/js/core/draw_utils.js Normal file
View File

@@ -0,0 +1,321 @@
/* typehints:start */
import { AtlasSprite } from "./sprites";
import { DrawParameters } from "./draw_parameters";
/* typehints:end */
import { Math_PI, Math_round, Math_atan2, Math_hypot, Math_floor } from "./builtins";
import { Vector } from "./vector";
import { Rectangle } from "./rectangle";
import { createLogger } from "./logging";
const logger = createLogger("draw_utils");
export function initDrawUtils() {
CanvasRenderingContext2D.prototype.beginRoundedRect = function (x, y, w, h, r) {
if (r < 0.05) {
this.beginPath();
this.rect(x, y, w, h);
return;
}
if (w < 2 * r) {
r = w / 2;
}
if (h < 2 * r) {
r = h / 2;
}
this.beginPath();
this.moveTo(x + r, y);
this.arcTo(x + w, y, x + w, y + h, r);
this.arcTo(x + w, y + h, x, y + h, r);
this.arcTo(x, y + h, x, y, r);
this.arcTo(x, y, x + w, y, r);
// this.closePath();
};
CanvasRenderingContext2D.prototype.beginCircle = function (x, y, r) {
if (r < 0.05) {
this.beginPath();
this.rect(x, y, 1, 1);
return;
}
this.beginPath();
this.arc(x, y, r, 0, 2.0 * Math_PI);
};
}
/**
*
* @param {object} param0
* @param {DrawParameters} param0.parameters
* @param {AtlasSprite} param0.sprite
* @param {number} param0.x
* @param {number} param0.y
* @param {number} param0.angle
* @param {number} param0.size
* @param {number=} param0.offsetX
* @param {number=} param0.offsetY
*/
export function drawRotatedSprite({ parameters, sprite, x, y, angle, size, offsetX = 0, offsetY = 0 }) {
parameters.context.translate(x, y);
parameters.context.rotate(angle);
sprite.drawCachedCentered(parameters, offsetX, offsetY, size, false);
parameters.context.rotate(-angle);
parameters.context.translate(-x, -y);
}
export function drawLineFast(context, { x1, x2, y1, y2, color = null, lineSize = 1 }) {
const dX = x2 - x1;
const dY = y2 - y1;
const angle = Math_atan2(dY, dX) + 0.0 * Math_PI;
const len = Math_hypot(dX, dY);
context.translate(x1, y1);
context.rotate(angle);
if (color) {
context.fillStyle = color;
}
context.fillRect(0, -lineSize / 2, len, lineSize);
context.rotate(-angle);
context.translate(-x1, -y1);
}
const INSIDE = 0;
const LEFT = 1;
const RIGHT = 2;
const BOTTOM = 4;
const TOP = 8;
// https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
function computeOutCode(x, y, xmin, xmax, ymin, ymax) {
let code = INSIDE;
if (x < xmin)
// to the left of clip window
code |= LEFT;
else if (x > xmax)
// to the right of clip window
code |= RIGHT;
if (y < ymin)
// below the clip window
code |= BOTTOM;
else if (y > ymax)
// above the clip window
code |= TOP;
return code;
}
// CohenSutherland clipping algorithm clips a line from
// P0 = (x0, y0) to P1 = (x1, y1) against a rectangle with
// diagonal from (xmin, ymin) to (xmax, ymax).
/**
*
* @param {CanvasRenderingContext2D} context
*/
export function drawLineFastClipped(context, rect, { x0, y0, x1, y1, color = null, lineSize = 1 }) {
const xmin = rect.x;
const ymin = rect.y;
const xmax = rect.right();
const ymax = rect.bottom();
// compute outcodes for P0, P1, and whatever point lies outside the clip rectangle
let outcode0 = computeOutCode(x0, y0, xmin, xmax, ymin, ymax);
let outcode1 = computeOutCode(x1, y1, xmin, xmax, ymin, ymax);
let accept = false;
// eslint-disable-next-line no-constant-condition
while (true) {
if (!(outcode0 | outcode1)) {
// bitwise OR is 0: both points inside window; trivially accept and exit loop
accept = true;
break;
} else if (outcode0 & outcode1) {
// bitwise AND is not 0: both points share an outside zone (LEFT, RIGHT, TOP,
// or BOTTOM), so both must be outside window; exit loop (accept is false)
break;
} else {
// failed both tests, so calculate the line segment to clip
// from an outside point to an intersection with clip edge
let x, y;
// At least one endpoint is outside the clip rectangle; pick it.
let outcodeOut = outcode0 ? outcode0 : outcode1;
// Now find the intersection point;
// use formulas:
// slope = (y1 - y0) / (x1 - x0)
// x = x0 + (1 / slope) * (ym - y0), where ym is ymin or ymax
// y = y0 + slope * (xm - x0), where xm is xmin or xmax
// No need to worry about divide-by-zero because, in each case, the
// outcode bit being tested guarantees the denominator is non-zero
if (outcodeOut & TOP) {
// point is above the clip window
x = x0 + ((x1 - x0) * (ymax - y0)) / (y1 - y0);
y = ymax;
} else if (outcodeOut & BOTTOM) {
// point is below the clip window
x = x0 + ((x1 - x0) * (ymin - y0)) / (y1 - y0);
y = ymin;
} else if (outcodeOut & RIGHT) {
// point is to the right of clip window
y = y0 + ((y1 - y0) * (xmax - x0)) / (x1 - x0);
x = xmax;
} else if (outcodeOut & LEFT) {
// point is to the left of clip window
y = y0 + ((y1 - y0) * (xmin - x0)) / (x1 - x0);
x = xmin;
}
// Now we move outside point to intersection point to clip
// and get ready for next pass.
if (outcodeOut == outcode0) {
x0 = x;
y0 = y;
outcode0 = computeOutCode(x0, y0, xmin, xmax, ymin, ymax);
} else {
x1 = x;
y1 = y;
outcode1 = computeOutCode(x1, y1, xmin, xmax, ymin, ymax);
}
}
}
if (accept) {
// Following functions are left for implementation by user based on
// their platform (OpenGL/graphics.h etc.)
// DrawRectangle(xmin, ymin, xmax, ymax);
// LineSegment(x0, y0, x1, y1);
drawLineFast(context, {
x1: x0,
y1: y0,
x2: x1,
y2: y1,
color,
lineSize,
});
}
}
/**
* Converts an HSL color value to RGB. Conversion formula
* adapted from http://en.wikipedia.org/wiki/HSL_color_space.
* Assumes h, s, and l are contained in the set [0, 1] and
* returns r, g, and b in the set [0, 255].
*
* @param {number} h The hue
* @param {number} s The saturation
* @param {number} l The lightness
* @return {Array} The RGB representation
*/
export function hslToRgb(h, s, l) {
let r;
let g;
let b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
// tslint:disable-next-line:no-shadowed-variable
const hue2rgb = function (p, q, t) {
if (t < 0) {
t += 1;
}
if (t > 1) {
t -= 1;
}
if (t < 1 / 6) {
return p + (q - p) * 6 * t;
}
if (t < 1 / 2) {
return q;
}
if (t < 2 / 3) {
return p + (q - p) * (2 / 3 - t) * 6;
}
return p;
};
let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
let p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math_round(r * 255), Math_round(g * 255), Math_round(b * 255)];
}
export function wrapText(context, text, x, y, maxWidth, lineHeight, stroke = false) {
var words = text.split(" ");
var line = "";
for (var n = 0; n < words.length; n++) {
var testLine = line + words[n] + " ";
var metrics = context.measureText(testLine);
var testWidth = metrics.width;
if (testWidth > maxWidth && n > 0) {
if (stroke) {
context.strokeText(line, x, y);
} else {
context.fillText(line, x, y);
}
line = words[n] + " ";
y += lineHeight;
} else {
line = testLine;
}
}
if (stroke) {
context.strokeText(line, x, y);
} else {
context.fillText(line, x, y);
}
}
/**
* Returns a rotated trapez, used for spotlight culling
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
* @param {number} leftHeight
* @param {number} angle
*/
export function rotateTrapezRightFaced(x, y, w, h, leftHeight, angle) {
const halfY = y + h / 2;
const points = [
new Vector(x, halfY - leftHeight / 2),
new Vector(x + w, y),
new Vector(x, halfY + leftHeight / 2),
new Vector(x + w, y + h),
];
return Rectangle.getAroundPointsRotated(points, angle);
}
/**
* Converts values from 0 .. 255 to values like 07, 7f, 5d etc
* @param {number} value
* @returns {string}
*/
export function mapClampedColorValueToHex(value) {
const hex = "0123456789abcdef";
return hex[Math_floor(value / 16)] + hex[value % 16];
}
/**
* Converts rgb to a hex string
* @param {number} r
* @param {number} g
* @param {number} b
* @returns {string}
*/
export function rgbToHex(r, g, b) {
return mapClampedColorValueToHex(r) + mapClampedColorValueToHex(g) + mapClampedColorValueToHex(b);
}

View File

@@ -0,0 +1,126 @@
import { logSection } from "./logging";
import { stringifyObjectContainingErrors } from "./logging";
import { removeAllChildren } from "./utils";
export let APPLICATION_ERROR_OCCURED = false;
/**
*
* @param {Event|string} message
* @param {string} source
* @param {number} lineno
* @param {number} colno
* @param {Error} source
*/
function catchErrors(message, source, lineno, colno, error) {
let fullPayload = JSON.parse(
stringifyObjectContainingErrors({
message,
source,
lineno,
colno,
error,
})
);
if (("" + message).indexOf("Script error.") >= 0) {
console.warn("Thirdparty script error:", message);
return;
}
if (("" + message).indexOf("NS_ERROR_FAILURE") >= 0) {
console.warn("Firefox NS_ERROR_FAILURE error:", message);
return;
}
if (("" + message).indexOf("Cannot read property 'postMessage' of null") >= 0) {
console.warn("Safari can not read post message error:", message);
return;
}
if (!G_IS_DEV && G_IS_BROWSER && ("" + source).indexOf("shapez.io") < 0) {
console.warn("Thirdparty error:", message);
return;
}
console.log("\n\n\n⚠\n\n\n");
console.log(" APPLICATION CRASHED ");
console.log("\n\n⚠\n\n\n");
logSection("APPLICATION CRASH", "#e53935");
console.log("Error:", message, "->", error);
console.log("Payload:", fullPayload);
if (window.Sentry && !window.anyModLoaded) {
window.Sentry.withScope(scope => {
window.Sentry.setTag("message", message);
window.Sentry.setTag("source", source);
window.Sentry.setExtra("message", message);
window.Sentry.setExtra("source", source);
window.Sentry.setExtra("lineno", lineno);
window.Sentry.setExtra("colno", colno);
window.Sentry.setExtra("error", error);
window.Sentry.setExtra("fullPayload", fullPayload);
try {
const userName = window.localStorage.getItem("tracking_context") || null;
window.Sentry.setTag("username", userName);
} catch (ex) {
// ignore
}
window.Sentry.captureException(error || source);
});
}
if (APPLICATION_ERROR_OCCURED) {
console.warn("ERROR: Only showing and submitting first error");
return;
}
APPLICATION_ERROR_OCCURED = true;
const element = document.createElement("div");
element.id = "applicationError";
const title = document.createElement("h1");
title.innerText = "Whoops!";
element.appendChild(title);
const desc = document.createElement("div");
desc.classList.add("desc");
desc.innerHTML = `
It seems the application crashed - I am sorry for that!<br /><br />
An anonymized crash report has been sent, and I will have a look as soon as possible.<br /><br />
If you have additional information how I can reproduce this error, please E-Mail me:&nbsp;
<a href="mailto:bugs@shapez.io?title=Application+Crash">bugs@shapez.io</a>`;
element.appendChild(desc);
const details = document.createElement("pre");
details.classList.add("details");
details.innerText = (error && error.stack) || message;
element.appendChild(details);
const inject = function () {
if (!G_IS_DEV) {
removeAllChildren(document.body);
}
if (document.body.parentElement) {
document.body.parentElement.appendChild(element);
} else {
document.body.appendChild(element);
}
};
if (document.body) {
inject();
} else {
setTimeout(() => {
inject();
}, 200);
}
return true;
}
window.onerror = catchErrors;

View File

@@ -0,0 +1,40 @@
export class ExplainedResult {
constructor(result = true, reason = null, additionalProps = {}) {
/** @type {boolean} */
this.result = result;
/** @type {string} */
this.reason = reason;
// Copy additional props
for (const key in additionalProps) {
this[key] = additionalProps[key];
}
}
isGood() {
return !!this.result;
}
isBad() {
return !this.result;
}
static good() {
return new ExplainedResult(true);
}
static bad(reason, additionalProps) {
return new ExplainedResult(false, reason, additionalProps);
}
static requireAll(...args) {
for (let i = 0; i < args.length; ++i) {
const subResult = args[i].call();
if (!subResult.isGood()) {
return subResult;
}
}
return this.good();
}
}

81
src/js/core/factory.js Normal file
View File

@@ -0,0 +1,81 @@
import { createLogger } from "./logging";
const logger = createLogger("factory");
// simple factory pattern
export class Factory {
constructor(id) {
this.id = id;
// Store array as well as dictionary, to speed up lookups
this.entries = [];
this.entryIds = [];
this.idToEntry = {};
}
getId() {
return this.id;
}
register(entry) {
// Extract id
const id = entry.getId();
assert(id, "Factory: Invalid id for class: " + entry);
// Check duplicates
assert(!this.idToEntry[id], "Duplicate factory entry for " + id);
// Insert
this.entries.push(entry);
this.entryIds.push(id);
this.idToEntry[id] = entry;
}
/**
* Checks if a given id is registered
* @param {string} id
* @returns {boolean}
*/
hasId(id) {
return !!this.idToEntry[id];
}
/**
* Finds an instance by a given id
* @param {string} id
* @returns {object}
*/
findById(id) {
const entry = this.idToEntry[id];
if (!entry) {
logger.error("Object with id", id, "is not registered on factory", this.id, "!");
assert(false, "Factory: Object with id '" + id + "' is not registered!");
return null;
}
return entry;
}
/**
* Returns all entries
* @returns {Array<object>}
*/
getEntries() {
return this.entries;
}
/**
* Returns all registered ids
* @returns {Array<string>}
*/
getAllIds() {
return this.entryIds;
}
/**
* Returns amount of stored entries
* @returns {number}
*/
getNumEntries() {
return this.entries.length;
}
}

365
src/js/core/game_state.js Normal file
View File

@@ -0,0 +1,365 @@
/* typehints:start */
import { Application } from "../application";
import { StateManager } from "./state_manager";
/* typehints:end */
import { globalConfig } from "./config";
import { ClickDetector } from "./click_detector";
import { logSection, createLogger } from "./logging";
import { InputReceiver } from "./input_receiver";
import { waitNextFrame } from "./utils";
import { RequestChannel } from "./request_channel";
import { MUSIC } from "../platform/sound";
const logger = createLogger("game_state");
/**
* Basic state of the game state machine. This is the base of the whole game
*/
export class GameState {
/**
* Constructs a new state with the given id
* @param {string} key The id of the state. We use ids to refer to states because otherwise we get
* circular references
*/
constructor(key) {
this.key = key;
/** @type {StateManager} */
this.stateManager = null;
/** @type {Application} */
this.app = null;
// Store if we are currently fading out
this.fadingOut = false;
/** @type {Array<ClickDetector>} */
this.clickDetectors = [];
// Every state captures keyboard events by default
this.inputReciever = new InputReceiver("state-" + key);
this.inputReciever.backButton.add(this.onBackButton, this);
// A channel we can use to perform async ops
this.asyncChannel = new RequestChannel();
}
//// GETTERS / HELPER METHODS ////
/**
* Returns the states key
* @returns {string}
*/
getKey() {
return this.key;
}
/**
* Returns the html element of the state
* @returns {HTMLElement}
*/
getDivElement() {
return document.getElementById("state_" + this.key);
}
/**
* Transfers to a new state
* @param {string} stateKey The id of the new state
*/
moveToState(stateKey, payload = {}, skipFadeOut = false) {
if (this.fadingOut) {
logger.warn("Skipping move to '" + stateKey + "' since already fading out");
return;
}
// Clean up event listeners
this.internalCleanUpClickDetectors();
// Fading
const fadeTime = this.internalGetFadeInOutTime();
const doFade = !skipFadeOut && this.getHasFadeOut() && fadeTime !== 0;
logger.log("Moving to", stateKey, "(fading=", doFade, ")");
if (doFade) {
this.htmlElement.classList.remove("arrived");
this.fadingOut = true;
setTimeout(() => {
this.stateManager.moveToState(stateKey, payload);
}, fadeTime);
} else {
this.stateManager.moveToState(stateKey, payload);
}
}
/**
*
* @param {string} nextStateId
* @param {object=} nextStatePayload
*/
watchAdAndMoveToState(nextStateId, nextStatePayload = {}) {
if (this.app.adProvider.getCanShowVideoAd() && this.app.isRenderable()) {
this.moveToState(
"WatchAdState",
{
nextStateId,
nextStatePayload,
},
true
);
} else {
this.moveToState(nextStateId, nextStatePayload);
}
}
/**
* Tracks clicks on a given element and calls the given callback *on this state*.
* If you want to call another function wrap it inside a lambda.
* @param {Element} element The element to track clicks on
* @param {function():void} handler The handler to call
* @param {import("./click_detector").ClickDetectorConstructorArgs=} args Click detector arguments
*/
trackClicks(element, handler, args = {}) {
const detector = new ClickDetector(element, args);
detector.click.add(handler, this);
if (G_IS_DEV) {
// Append a source so we can check where the click detector is from
// @ts-ignore
detector._src = "state-" + this.key;
}
this.clickDetectors.push(detector);
}
/**
* Cancels all promises on the api as well as our async channel
*/
cancelAllAsyncOperations() {
this.asyncChannel.cancelAll();
// TODO
// this.app.api.cancelRequests();
}
//// CALLBACKS ////
/**
* Callback when entering the state, to be overriddemn
* @param {any} payload Arbitrary data passed from the state which we are transferring from
*/
onEnter(payload) {}
/**
* Callback when leaving the state
*/
onLeave() {}
/**
* Callback before leaving the game state or when the page is unloaded
*/
onBeforeExit() {}
/**
* Callback when the app got paused (on android, this means in background)
*/
onAppPause() {}
/**
* Callback when the app got resumed (on android, this means in foreground again)
*/
onAppResume() {}
/**
* Render callback
* @param {number} dt Delta time in ms since last render
*/
onRender(dt) {}
/**
* Background tick callback, called while the game is inactiev
* @param {number} dt Delta time in ms since last tick
*/
onBackgroundTick(dt) {}
/**
* Called when the screen resized
* @param {number} w window/screen width
* @param {number} h window/screen height
*/
onResized(w, h) {}
/**
* Internal backbutton handler, called when the hardware back button is pressed or
* the escape key is pressed
*/
onBackButton() {}
//// INTERFACE ////
/**
* Should return how many mulliseconds to fade in / out the state. Not recommended to override!
* @returns {number} Time in milliseconds to fade out
*/
getInOutFadeTime() {
if (globalConfig.debug.noArtificialDelays) {
return 0;
}
return 200;
}
/**
* Should return whether to fade in the game state. This will then apply the right css classes
* for the fadein.
* @returns {boolean}
*/
getHasFadeIn() {
return true;
}
/**
* Should return whether to fade out the game state. This will then apply the right css classes
* for the fadeout and wait the delay before moving states
* @returns {boolean}
*/
getHasFadeOut() {
return true;
}
/**
* Returns if this state should get paused if it does not have focus
* @returns {boolean} true to pause the updating of the game
*/
getPauseOnFocusLost() {
return true;
}
/**
* Should return the html code of the state.
* @returns {string}
*/
getInnerHTML() {
abstract;
return "";
}
/**
* Returns if the state has an unload confirmation, this is the
* "Are you sure you want to leave the page" message.
*/
getHasUnloadConfirmation() {
return false;
}
/**
* Should return the theme music for this state
* @returns {string|null}
*/
getThemeMusic() {
return MUSIC.mainMenu;
}
////////////////////
//// INTERNAL ////
/**
* Internal callback from the manager. Do not override!
* @param {StateManager} stateManager
*/
internalRegisterCallback(stateManager, app) {
assert(stateManager, "No state manager");
assert(app, "No app");
this.stateManager = stateManager;
this.app = app;
}
/**
* Internal callback when entering the state. Do not override!
* @param {any} payload Arbitrary data passed from the state which we are transferring from
* @param {boolean} callCallback Whether to call the onEnter callback
*/
internalEnterCallback(payload, callCallback = true) {
logSection(this.key, "#26a69a");
this.app.inputMgr.pushReciever(this.inputReciever);
this.htmlElement = this.getDivElement();
this.htmlElement.classList.add("active");
// Apply classes in the next frame so the css transition keeps up
waitNextFrame().then(() => {
if (this.htmlElement) {
this.htmlElement.classList.remove("fadingOut");
this.htmlElement.classList.remove("fadingIn");
}
});
// Call handler
if (callCallback) {
this.onEnter(payload);
}
}
/**
* Internal callback when the state is left. Do not override!
*/
internalLeaveCallback() {
this.onLeave();
this.htmlElement.classList.remove("active");
this.app.inputMgr.popReciever(this.inputReciever);
this.internalCleanUpClickDetectors();
this.asyncChannel.cancelAll();
}
/**
* Internal callback *before* the state is left. Also is called on page unload
*/
internalOnBeforeExitCallback() {
this.onBeforeExit();
}
/**
* Internal app pause callback
*/
internalOnAppPauseCallback() {
this.onAppPause();
}
/**
* Internal app resume callback
*/
internalOnAppResumeCallback() {
this.onAppResume();
}
/**
* Cleans up all click detectors
*/
internalCleanUpClickDetectors() {
if (this.clickDetectors) {
for (let i = 0; i < this.clickDetectors.length; ++i) {
this.clickDetectors[i].cleanup();
}
this.clickDetectors = [];
}
}
/**
* Internal method to get the HTML of the game state.
* @returns {string}
*/
internalGetFullHtml() {
return this.getInnerHTML();
}
/**
* Internal method to compute the time to fade in / out
* @returns {number} time to fade in / out in ms
*/
internalGetFadeInOutTime() {
if (G_IS_DEV && globalConfig.debug.fastGameEnter) {
return 1;
}
if (G_IS_DEV && globalConfig.debug.noArtificialDelays) {
return 1;
}
return this.getInOutFadeTime();
}
}

View File

@@ -0,0 +1,35 @@
import { SingletonFactory } from "./singleton_factory";
import { Factory } from "./factory";
/* typehints:start */
import { BaseGameSpeed } from "../game/time/base_game_speed";
import { Component } from "../game/component";
import { BaseItem } from "../game/base_item";
import { MetaBuilding } from "../game/meta_building";
/* typehints:end */
// These factories are here to remove circular dependencies
/** @type {SingletonFactoryTemplate<MetaBuilding>} */
export let gMetaBuildingRegistry = new SingletonFactory();
/** @type {Object.<string, Array<typeof MetaBuilding>>} */
export let gBuildingsByCategory = null;
/** @type {FactoryTemplate<Component>} */
export let gComponentRegistry = new Factory("component");
/** @type {FactoryTemplate<BaseGameSpeed>} */
export let gGameSpeedRegistry = new Factory("gamespeed");
/** @type {FactoryTemplate<BaseItem>} */
export let gItemRegistry = new Factory("item");
// Helpers
/**
* @param {Object.<string, Array<typeof MetaBuilding>>} buildings
*/
export function initBuildingsByCategory(buildings) {
gBuildingsByCategory = buildings;
}

17
src/js/core/globals.js Normal file
View File

@@ -0,0 +1,17 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
/**
* Used for the bug reporter, and the click detector which both have no handles to this.
* It would be nicer to have no globals, but this is the only one. I promise!
* @type {Application} */
export let GLOBAL_APP = null;
/**
* @param {Application} app
*/
export function setGlobalApp(app) {
assert(!GLOBAL_APP, "Create application twice!");
GLOBAL_APP = app;
}

View File

@@ -0,0 +1,235 @@
/* typehints:start */
import { Application } from "../application";
import { InputReceiver } from "./input_receiver";
/* typehints:end */
import { Signal, STOP_PROPAGATION } from "./signal";
import { createLogger } from "./logging";
import { arrayDeleteValue, fastArrayDeleteValue } from "./utils";
const logger = createLogger("input_distributor");
export class InputDistributor {
/**
*
* @param {Application} app
*/
constructor(app) {
this.app = app;
/** @type {Array<InputReceiver>} */
this.recieverStack = [];
/** @type {Array<function(any) : boolean>} */
this.filters = [];
this.shiftIsDown = false;
this.altIsDown = false;
this.bindToEvents();
}
/**
* Attaches a new filter which can filter and reject events
* @param {function(any): boolean} filter
*/
installFilter(filter) {
this.filters.push(filter);
}
/**
* Removes an attached filter
* @param {function(any) : boolean} filter
*/
dismountFilter(filter) {
fastArrayDeleteValue(this.filters, filter);
}
/**
* @param {InputReceiver} reciever
*/
pushReciever(reciever) {
if (this.isRecieverAttached(reciever)) {
assert(false, "Can not add reciever " + reciever.context + " twice");
logger.error("Can not add reciever", reciever.context, "twice");
return;
}
this.recieverStack.push(reciever);
if (this.recieverStack.length > 10) {
logger.error(
"Reciever stack is huge, probably some dead receivers arround:",
this.recieverStack.map(x => x.context)
);
}
}
/**
* @param {InputReceiver} reciever
*/
popReciever(reciever) {
if (this.recieverStack.indexOf(reciever) < 0) {
assert(false, "Can not pop reciever " + reciever.context + " since its not contained");
logger.error("Can not pop reciever", reciever.context, "since its not contained");
return;
}
if (this.recieverStack[this.recieverStack.length - 1] !== reciever) {
logger.warn(
"Popping reciever",
reciever.context,
"which is not on top of the stack. Stack is: ",
this.recieverStack.map(x => x.context)
);
}
arrayDeleteValue(this.recieverStack, reciever);
}
/**
* @param {InputReceiver} reciever
*/
isRecieverAttached(reciever) {
return this.recieverStack.indexOf(reciever) >= 0;
}
/**
* @param {InputReceiver} reciever
*/
isRecieverOnTop(reciever) {
return (
this.isRecieverAttached(reciever) &&
this.recieverStack[this.recieverStack.length - 1] === reciever
);
}
/**
* @param {InputReceiver} reciever
*/
makeSureAttachedAndOnTop(reciever) {
this.makeSureDetached(reciever);
this.pushReciever(reciever);
}
/**
* @param {InputReceiver} reciever
*/
makeSureDetached(reciever) {
if (this.isRecieverAttached(reciever)) {
arrayDeleteValue(this.recieverStack, reciever);
}
}
/**
*
* @param {InputReceiver} reciever
*/
destroyReceiver(reciever) {
this.makeSureDetached(reciever);
reciever.cleanup();
}
// Internal
getTopReciever() {
if (this.recieverStack.length > 0) {
return this.recieverStack[this.recieverStack.length - 1];
}
return null;
}
bindToEvents() {
window.addEventListener("popstate", this.handleBackButton.bind(this), false);
document.addEventListener("backbutton", this.handleBackButton.bind(this), false);
window.addEventListener("keydown", this.handleKeydown.bind(this));
window.addEventListener("keyup", this.handleKeyup.bind(this));
window.addEventListener("blur", this.handleBlur.bind(this));
}
forwardToReceiver(eventId, payload = null) {
// Check filters
for (let i = 0; i < this.filters.length; ++i) {
if (!this.filters[i](eventId)) {
return STOP_PROPAGATION;
}
}
const reciever = this.getTopReciever();
if (!reciever) {
logger.warn("Dismissing event because not reciever was found:", eventId);
return;
}
const signal = reciever[eventId];
assert(signal instanceof Signal, "Not a valid event id");
return signal.dispatch(payload);
}
/**
* @param {Event} event
*/
handleBackButton(event) {
event.preventDefault();
event.stopPropagation();
this.forwardToReceiver("backButton");
}
/**
* Handles when the page got blurred
*/
handleBlur() {
this.shiftIsDown = false;
this.forwardToReceiver("pageBlur", {});
this.forwardToReceiver("shiftUp", {});
}
/**
* @param {KeyboardEvent} event
*/
handleKeydown(event) {
if (event.keyCode === 16) {
this.shiftIsDown = true;
}
if (
// TAB
event.keyCode === 9 ||
// F1 - F10
(event.keyCode >= 112 && event.keyCode < 122 && !G_IS_DEV)
) {
event.preventDefault();
}
if (
this.forwardToReceiver("keydown", {
keyCode: event.keyCode,
shift: event.shiftKey,
alt: event.altKey,
event,
}) === STOP_PROPAGATION
) {
return;
}
const code = event.keyCode;
if (code === 27) {
// Escape key
event.preventDefault();
event.stopPropagation();
return this.forwardToReceiver("backButton");
}
}
/**
* @param {KeyboardEvent} event
*/
handleKeyup(event) {
if (event.keyCode === 16) {
this.shiftIsDown = false;
this.forwardToReceiver("shiftUp", {});
}
this.forwardToReceiver("keyup", {
keyCode: event.keyCode,
shift: event.shiftKey,
alt: event.altKey,
});
}
}

View File

@@ -0,0 +1,25 @@
import { Signal } from "./signal";
export class InputReceiver {
constructor(context = "unknown") {
this.context = context;
this.backButton = new Signal();
this.keydown = new Signal();
this.keyup = new Signal();
this.pageBlur = new Signal();
this.shiftUp = new Signal();
// Dispatched on destroy
this.destroyed = new Signal();
}
cleanup() {
this.backButton.removeAll();
this.keydown.removeAll();
this.keyup.removeAll();
this.destroyed.dispatch();
}
}

243
src/js/core/loader.js Normal file
View File

@@ -0,0 +1,243 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
import { AtlasDefinition } from "./atlas_definitions";
import { makeOffscreenBuffer } from "./buffer_utils";
import { AtlasSprite, BaseSprite, RegularSprite, SpriteAtlasLink } from "./sprites";
import { cachebust } from "./cachebust";
import { createLogger } from "./logging";
const logger = createLogger("loader");
const missingSpriteIds = {};
class LoaderImpl {
constructor() {
/** @type {Application} */
this.app = null;
/** @type {Map<string, BaseSprite>} */
this.sprites = new Map();
this.rawImages = [];
}
linkAppAfterBoot(app) {
this.app = app;
this.makeSpriteNotFoundCanvas();
}
/**
* Fetches a given sprite from the cache
* @param {string} key
* @returns {BaseSprite}
*/
getSpriteInternal(key) {
const sprite = this.sprites.get(key);
if (!sprite) {
if (!missingSpriteIds[key]) {
// Only show error once
missingSpriteIds[key] = true;
logger.error("Sprite '" + key + "' not found!");
}
return this.spriteNotFoundSprite;
}
return sprite;
}
/**
* Returns an atlas sprite from the cache
* @param {string} key
* @returns {AtlasSprite}
*/
getSprite(key) {
const sprite = this.getSpriteInternal(key);
assert(sprite instanceof AtlasSprite || sprite === this.spriteNotFoundSprite, "Not an atlas sprite");
return /** @type {AtlasSprite} */ (sprite);
}
/**
* Retursn a regular sprite from the cache
* @param {string} key
* @returns {RegularSprite}
*/
getRegularSprite(key) {
const sprite = this.getSpriteInternal(key);
assert(
sprite instanceof RegularSprite || sprite === this.spriteNotFoundSprite,
"Not a regular sprite"
);
return /** @type {RegularSprite} */ (sprite);
}
/**
*
* @param {string} key
* @returns {Promise<HTMLImageElement|null>}
*/
internalPreloadImage(key) {
const url = cachebust("res/" + key);
const image = new Image();
let triesSoFar = 0;
return Promise.race([
new Promise((resolve, reject) => {
setTimeout(reject, G_IS_DEV ? 3000 : 60000);
}),
new Promise(resolve => {
image.onload = () => {
image.onerror = null;
image.onload = null;
if (typeof image.decode === "function") {
// SAFARI: Image.decode() fails on safari with svgs -> we dont want to fail
// on that
// FIREFOX: Decode never returns if the image is in cache, so call it in background
image.decode().then(
() => null,
() => null
);
}
resolve(image);
};
image.onerror = reason => {
logger.warn("Failed to load '" + url + "':", reason);
if (++triesSoFar < 4) {
logger.log("Retrying to load image from", url);
image.src = url + "?try=" + triesSoFar;
} else {
logger.warn("Failed to load", url, "after", triesSoFar, "tries with reason", reason);
image.onerror = null;
image.onload = null;
resolve(null);
}
};
image.src = url;
}),
]);
}
/**
* Preloads a sprite
* @param {string} key
* @returns {Promise<void>}
*/
preloadCSSSprite(key) {
return this.internalPreloadImage(key).then(image => {
if (key.indexOf("game_misc") >= 0) {
// Allow access to regular sprites
this.sprites.set(key, new RegularSprite(image, image.width, image.height));
}
this.rawImages.push(image);
});
}
/**
* Preloads an atlas
* @param {AtlasDefinition} atlas
* @returns {Promise<void>}
*/
preloadAtlas(atlas) {
return this.internalPreloadImage(atlas.getFullSourcePath()).then(image => {
// @ts-ignore
image.label = atlas.sourceFileName;
return this.internalParseAtlas(atlas, image);
});
}
/**
*
* @param {AtlasDefinition} atlas
* @param {HTMLImageElement} loadedImage
*/
internalParseAtlas(atlas, loadedImage) {
this.rawImages.push(loadedImage);
for (const spriteKey in atlas.sourceData) {
const spriteData = atlas.sourceData[spriteKey];
let sprite = /** @type {AtlasSprite} */ (this.sprites.get(spriteKey));
if (!sprite) {
sprite = new AtlasSprite({
spriteName: spriteKey,
});
this.sprites.set(spriteKey, sprite);
}
const link = new SpriteAtlasLink({
packedX: spriteData.frame.x,
packedY: spriteData.frame.y,
packedW: spriteData.frame.w,
packedH: spriteData.frame.h,
packOffsetX: spriteData.spriteSourceSize.x,
packOffsetY: spriteData.spriteSourceSize.y,
atlas: loadedImage,
w: spriteData.sourceSize.w,
h: spriteData.sourceSize.h,
});
sprite.linksByResolution[atlas.meta.scale] = link;
}
}
/**
* Creates the links for the sprites after the atlas has been loaded. Used so we
* don't have to store duplicate sprites.
*/
createAtlasLinks() {
// NOT USED
}
/**
* Makes the canvas which shows the question mark, shown when a sprite was not found
*/
makeSpriteNotFoundCanvas() {
const dims = 128;
const [canvas, context] = makeOffscreenBuffer(dims, dims, {
smooth: false,
label: "not-found-sprite",
});
context.fillStyle = "#f77";
context.fillRect(0, 0, dims, dims);
context.textAlign = "center";
context.textBaseline = "middle";
context.fillStyle = "#eee";
context.font = "25px Arial";
context.fillText("???", dims / 2, dims / 2);
// TODO: Not sure why this is set here
// @ts-ignore
canvas.src = "not-found";
const resolutions = ["0.1", "0.25", "0.5", "0.75", "1"];
const sprite = new AtlasSprite({
spriteName: "not-found",
});
for (let i = 0; i < resolutions.length; ++i) {
const res = resolutions[i];
const link = new SpriteAtlasLink({
packedX: 0,
packedY: 0,
w: dims,
h: dims,
packOffsetX: 0,
packOffsetY: 0,
packedW: dims,
packedH: dims,
atlas: canvas,
});
sprite.linksByResolution[res] = link;
}
this.spriteNotFoundSprite = sprite;
}
}
export const Loader = new LoaderImpl();

249
src/js/core/logging.js Normal file
View File

@@ -0,0 +1,249 @@
import { globalConfig } from "../core/config";
import { Math_floor, performanceNow } from "./builtins";
const circularJson = require("circular-json");
/*
Logging functions
- To be extended
*/
/**
* Base logger class
*/
class Logger {
constructor(context) {
this.context = context;
}
debug(...args) {
globalDebug(this.context, ...args);
}
log(...args) {
globalLog(this.context, ...args);
}
warn(...args) {
globalWarn(this.context, ...args);
}
error(...args) {
globalError(this.context, ...args);
}
}
export function createLogger(context) {
return new Logger(context);
}
function prepareObjectForLogging(obj, maxDepth = 1) {
if (!window.Sentry) {
// Not required without sentry
return obj;
}
if (typeof obj !== "object" && !Array.isArray(obj)) {
return obj;
}
const result = {};
for (const key in obj) {
const val = obj[key];
if (typeof val === "object") {
if (maxDepth > 0) {
result[key] = prepareObjectForLogging(val, maxDepth - 1);
} else {
result[key] = "[object]";
}
} else {
result[key] = val;
}
}
return result;
}
/**
* Serializes an error
* @param {Error|ErrorEvent} err
*/
export function serializeError(err) {
if (!err) {
return null;
}
const result = {
type: err.constructor.name,
};
if (err instanceof Error) {
result.message = err.message;
result.name = err.name;
result.stack = err.stack;
result.type = "{type.Error}";
} else if (err instanceof ErrorEvent) {
result.filename = err.filename;
result.message = err.message;
result.lineno = err.lineno;
result.colno = err.colno;
result.type = "{type.ErrorEvent}";
if (err.error) {
result.error = serializeError(err.error);
} else {
result.error = "{not-provided}";
}
} else {
result.type = "{unkown-type:" + typeof err + "}";
}
return result;
}
/**
* Serializes an event
* @param {Event} event
*/
function serializeEvent(event) {
let result = {
type: "{type.Event:" + typeof event + "}",
};
result.eventType = event.type;
return result;
}
/**
* Prepares a json payload
* @param {string} key
* @param {any} value
*/
function preparePayload(key, value) {
if (value instanceof Error || value instanceof ErrorEvent) {
return serializeError(value);
}
if (value instanceof Event) {
return serializeEvent(value);
}
if (typeof value === "undefined") {
return null;
}
return value;
}
/**
* Stringifies an object containing circular references and errors
* @param {any} payload
*/
export function stringifyObjectContainingErrors(payload) {
return circularJson.stringify(payload, preparePayload);
}
export function globalDebug(context, ...args) {
if (G_IS_DEV) {
logInternal(context, console.log, prepareArgsForLogging(args));
}
}
export function globalLog(context, ...args) {
// eslint-disable-next-line no-console
logInternal(context, console.log, prepareArgsForLogging(args));
}
export function globalWarn(context, ...args) {
// eslint-disable-next-line no-console
logInternal(context, console.warn, prepareArgsForLogging(args));
}
export function globalError(context, ...args) {
args = prepareArgsForLogging(args);
// eslint-disable-next-line no-console
logInternal(context, console.error, args);
if (window.Sentry) {
window.Sentry.withScope(scope => {
scope.setExtra("args", args);
window.Sentry.captureMessage(internalBuildStringFromArgs(args), "error");
});
}
}
function prepareArgsForLogging(args) {
let result = [];
for (let i = 0; i < args.length; ++i) {
result.push(prepareObjectForLogging(args[i]));
}
return result;
}
/**
* @param {Array<any>} args
*/
function internalBuildStringFromArgs(args) {
let result = [];
for (let i = 0; i < args.length; ++i) {
let arg = args[i];
if (
typeof arg === "string" ||
typeof arg === "number" ||
typeof arg === "boolean" ||
arg === null ||
arg === undefined
) {
result.push("" + arg);
} else if (arg instanceof Error) {
result.push(arg.message);
} else {
result.push("[object]");
}
}
return result.join(" ");
}
export function logSection(name, color) {
while (name.length <= 14) {
name = " " + name + " ";
}
name = name.padEnd(19, " ");
const lineCss =
"letter-spacing: -3px; color: " + color + "; font-size: 6px; background: #eee; color: #eee;";
const line = "%c----------------------------";
console.log("\n" + line + " %c" + name + " " + line + "\n", lineCss, "color: " + color, lineCss);
}
function extractHandleContext(handle) {
let context = handle || "unknown";
if (handle && handle.constructor && handle.constructor.name) {
context = handle.constructor.name;
if (context === "String") {
context = handle;
}
}
if (handle && handle.name) {
context = handle.name;
}
return context + "";
}
function logInternal(handle, consoleMethod, args) {
const context = extractHandleContext(handle).padEnd(20, " ");
const labelColor = handle && handle.LOG_LABEL_COLOR ? handle.LOG_LABEL_COLOR : "#aaa";
if (G_IS_DEV && globalConfig.debug.logTimestamps) {
const timestamp = "⏱ %c" + (Math_floor(performanceNow()) + "").padEnd(6, " ") + "";
consoleMethod.call(
console,
timestamp + " %c" + context,
"color: #7f7;",
"color: " + labelColor + ";",
...args
);
} else {
if (G_IS_DEV && !globalConfig.debug.disableLoggingLogSources) {
consoleMethod.call(console, "%c" + context, "color: " + labelColor, ...args);
} else {
consoleMethod.call(console, ...args);
}
}
}

493
src/js/core/lzstring.js Normal file
View File

@@ -0,0 +1,493 @@
// Copyright (c) 2013 Pieroxy <pieroxy@pieroxy.net>
// This work is free. You can redistribute it and/or modify it
// under the terms of the WTFPL, Version 2
// For more information see LICENSE.txt or http://www.wtfpl.net/
//
// For more information, the home page:
// http://pieroxy.net/blog/pages/lz-string/testing.html
//
// LZ-based compression algorithm, version 1.4.4
const fromCharCode = String.fromCharCode;
const hasOwnProperty = Object.prototype.hasOwnProperty;
const keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
const baseReverseDic = {};
function getBaseValue(alphabet, character) {
if (!baseReverseDic[alphabet]) {
baseReverseDic[alphabet] = {};
for (let i = 0; i < alphabet.length; i++) {
baseReverseDic[alphabet][alphabet.charAt(i)] = i;
}
}
return baseReverseDic[alphabet][character];
}
//compress into uint8array (UCS-2 big endian format)
export function compressU8(uncompressed) {
let compressed = compress(uncompressed);
let buf = new Uint8Array(compressed.length * 2); // 2 bytes per character
for (let i = 0, TotalLen = compressed.length; i < TotalLen; i++) {
let current_value = compressed.charCodeAt(i);
buf[i * 2] = current_value >>> 8;
buf[i * 2 + 1] = current_value % 256;
}
return buf;
}
// Compreses with header
/**
* @param {string} uncompressed
* @param {number} header
*/
export function compressU8WHeader(uncompressed, header) {
let compressed = compress(uncompressed);
let buf = new Uint8Array(2 + compressed.length * 2); // 2 bytes per character
buf[0] = header >>> 8;
buf[1] = header % 256;
for (let i = 0, TotalLen = compressed.length; i < TotalLen; i++) {
let current_value = compressed.charCodeAt(i);
buf[2 + i * 2] = current_value >>> 8;
buf[2 + i * 2 + 1] = current_value % 256;
}
return buf;
}
//decompress from uint8array (UCS-2 big endian format)
/**
*
* @param {Uint8Array} compressed
*/
export function decompressU8WHeader(compressed) {
// let buf = new Array(compressed.length / 2); // 2 bytes per character
// for (let i = 0, TotalLen = buf.length; i < TotalLen; i++) {
// buf[i] = compressed[i * 2] * 256 + compressed[i * 2 + 1];
// }
// let result = [];
// buf.forEach(function (c) {
// result.push(fromCharCode(c));
// });
let result = [];
for (let i = 2, n = compressed.length; i < n; i += 2) {
const code = compressed[i] * 256 + compressed[i + 1];
result.push(fromCharCode(code));
}
return decompress(result.join(""));
}
//compress into a string that is already URI encoded
export function compressX64(input) {
if (input == null) return "";
return _compress(input, 6, function (a) {
return keyStrUriSafe.charAt(a);
});
}
//decompress from an output of compressToEncodedURIComponent
export function decompressX64(input) {
if (input == null) return "";
if (input == "") return null;
input = input.replace(/ /g, "+");
return _decompress(input.length, 32, function (index) {
return getBaseValue(keyStrUriSafe, input.charAt(index));
});
}
function compress(uncompressed) {
return _compress(uncompressed, 16, function (a) {
return fromCharCode(a);
});
}
function _compress(uncompressed, bitsPerChar, getCharFromInt) {
if (uncompressed == null) return "";
let i,
value,
context_dictionary = {},
context_dictionaryToCreate = {},
context_c = "",
context_wc = "",
context_w = "",
context_enlargeIn = 2, // Compensate for the first entry which should not count
context_dictSize = 3,
context_numBits = 2,
context_data = [],
context_data_val = 0,
context_data_position = 0,
ii;
for (ii = 0; ii < uncompressed.length; ii += 1) {
context_c = uncompressed.charAt(ii);
if (!hasOwnProperty.call(context_dictionary, context_c)) {
context_dictionary[context_c] = context_dictSize++;
context_dictionaryToCreate[context_c] = true;
}
context_wc = context_w + context_c;
if (hasOwnProperty.call(context_dictionary, context_wc)) {
context_w = context_wc;
} else {
if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
if (context_w.charCodeAt(0) < 256) {
for (i = 0; i < context_numBits; i++) {
context_data_val = context_data_val << 1;
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
}
value = context_w.charCodeAt(0);
for (i = 0; i < 8; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
} else {
value = 1;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | value;
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = 0;
}
value = context_w.charCodeAt(0);
for (i = 0; i < 16; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
delete context_dictionaryToCreate[context_w];
} else {
value = context_dictionary[context_w];
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
// Add wc to the dictionary.
context_dictionary[context_wc] = context_dictSize++;
context_w = String(context_c);
}
}
// Output the code for w.
if (context_w !== "") {
if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
if (context_w.charCodeAt(0) < 256) {
for (i = 0; i < context_numBits; i++) {
context_data_val = context_data_val << 1;
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
}
value = context_w.charCodeAt(0);
for (i = 0; i < 8; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
} else {
value = 1;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | value;
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = 0;
}
value = context_w.charCodeAt(0);
for (i = 0; i < 16; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
delete context_dictionaryToCreate[context_w];
} else {
value = context_dictionary[context_w];
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
}
// Mark the end of the stream
value = 2;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
// Flush the last char
// eslint-disable-next-line no-constant-condition
while (true) {
context_data_val = context_data_val << 1;
if (context_data_position == bitsPerChar - 1) {
context_data.push(getCharFromInt(context_data_val));
break;
} else context_data_position++;
}
return context_data.join("");
}
function decompress(compressed) {
if (compressed == null) return "";
if (compressed == "") return null;
return _decompress(compressed.length, 32768, function (index) {
return compressed.charCodeAt(index);
});
}
function _decompress(length, resetValue, getNextValue) {
let dictionary = [],
next,
enlargeIn = 4,
dictSize = 4,
numBits = 3,
entry = "",
result = [],
i,
w,
bits,
resb,
maxpower,
power,
c,
data = { val: getNextValue(0), position: resetValue, index: 1 };
for (i = 0; i < 3; i += 1) {
dictionary[i] = i;
}
bits = 0;
maxpower = Math.pow(2, 2);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
switch ((next = bits)) {
case 0:
bits = 0;
maxpower = Math.pow(2, 8);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
c = fromCharCode(bits);
break;
case 1:
bits = 0;
maxpower = Math.pow(2, 16);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
c = fromCharCode(bits);
break;
case 2:
return "";
}
dictionary[3] = c;
w = c;
result.push(c);
// eslint-disable-next-line no-constant-condition
while (true) {
if (data.index > length) {
return "";
}
bits = 0;
maxpower = Math.pow(2, numBits);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
switch ((c = bits)) {
case 0:
bits = 0;
maxpower = Math.pow(2, 8);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
dictionary[dictSize++] = fromCharCode(bits);
c = dictSize - 1;
enlargeIn--;
break;
case 1:
bits = 0;
maxpower = Math.pow(2, 16);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb > 0 ? 1 : 0) * power;
power <<= 1;
}
dictionary[dictSize++] = fromCharCode(bits);
c = dictSize - 1;
enlargeIn--;
break;
case 2:
return result.join("");
}
if (enlargeIn == 0) {
enlargeIn = Math.pow(2, numBits);
numBits++;
}
if (dictionary[c]) {
// @ts-ignore
entry = dictionary[c];
} else {
if (c === dictSize) {
entry = w + w.charAt(0);
} else {
return null;
}
}
result.push(entry);
// Add w+entry[0] to the dictionary.
dictionary[dictSize++] = w + entry.charAt(0);
enlargeIn--;
w = entry;
if (enlargeIn == 0) {
enlargeIn = Math.pow(2, numBits);
numBits++;
}
}
}

175
src/js/core/perlin_noise.js Normal file
View File

@@ -0,0 +1,175 @@
import { perlinNoiseData } from "./perlin_noise_data";
import { Math_sqrt } from "./builtins";
class Grad {
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
dot2(x, y) {
return this.x * x + this.y * y;
}
dot3(x, y, z) {
return this.x * x + this.y * y + this.z * z;
}
}
function fade(t) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function lerp(a, b, t) {
return (1 - t) * a + t * b;
}
const F2 = 0.5 * (Math_sqrt(3) - 1);
const G2 = (3 - Math_sqrt(3)) / 6;
const F3 = 1 / 3;
const G3 = 1 / 6;
export class PerlinNoise {
constructor(seed) {
this.perm = new Array(512);
this.gradP = new Array(512);
this.grad3 = [
new Grad(1, 1, 0),
new Grad(-1, 1, 0),
new Grad(1, -1, 0),
new Grad(-1, -1, 0),
new Grad(1, 0, 1),
new Grad(-1, 0, 1),
new Grad(1, 0, -1),
new Grad(-1, 0, -1),
new Grad(0, 1, 1),
new Grad(0, -1, 1),
new Grad(0, 1, -1),
new Grad(0, -1, -1),
];
this.seed = seed;
this.initializeFromSeed(seed);
}
initializeFromSeed(seed) {
const P = perlinNoiseData;
if (seed > 0 && seed < 1) {
// Scale the seed out
seed *= 65536;
}
seed = Math.floor(seed);
if (seed < 256) {
seed |= seed << 8;
}
for (let i = 0; i < 256; i++) {
let v;
if (i & 1) {
v = P[i] ^ (seed & 255);
} else {
v = P[i] ^ ((seed >> 8) & 255);
}
this.perm[i] = this.perm[i + 256] = v;
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12];
}
}
/**
* 2d Perlin Noise
* @param {number} x
* @param {number} y
* @returns {number}
*/
computePerlin2(x, y) {
// Find unit grid cell containing point
let X = Math.floor(x),
Y = Math.floor(y);
// Get relative xy coordinates of point within that cell
x = x - X;
y = y - Y;
// Wrap the integer cells at 255 (smaller integer period can be introduced here)
X = X & 255;
Y = Y & 255;
// Calculate noise contributions from each of the four corners
let n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
let n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
let n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
let n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
// Compute the fade curve value for x
let u = fade(x);
// Interpolate the four results
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), fade(y));
}
computeSimplex2(xin, yin) {
var n0, n1, n2; // Noise contributions from the three corners
// Skew the input space to determine which simplex cell we're in
var s = (xin + yin) * F2; // Hairy factor for 2D
var i = Math.floor(xin + s);
var j = Math.floor(yin + s);
var t = (i + j) * G2;
var x0 = xin - i + t; // The x,y distances from the cell origin, unskewed.
var y0 = yin - j + t;
// For the 2D case, the simplex shape is an equilateral triangle.
// Determine which simplex we are in.
var i1, j1; // Offsets for second (middle) corner of simplex in (i,j) coords
if (x0 > y0) {
// lower triangle, XY order: (0,0)->(1,0)->(1,1)
i1 = 1;
j1 = 0;
} else {
// upper triangle, YX order: (0,0)->(0,1)->(1,1)
i1 = 0;
j1 = 1;
}
// A step of (1,0) in (i,j) means a step of (1-c,-c) in (x,y), and
// a step of (0,1) in (i,j) means a step of (-c,1-c) in (x,y), where
// c = (3-sqrt(3))/6
var x1 = x0 - i1 + G2; // Offsets for middle corner in (x,y) unskewed coords
var y1 = y0 - j1 + G2;
var x2 = x0 - 1 + 2 * G2; // Offsets for last corner in (x,y) unskewed coords
var y2 = y0 - 1 + 2 * G2;
// Work out the hashed gradient indices of the three simplex corners
i &= 255;
j &= 255;
var gi0 = this.gradP[i + this.perm[j]];
var gi1 = this.gradP[i + i1 + this.perm[j + j1]];
var gi2 = this.gradP[i + 1 + this.perm[j + 1]];
// Calculate the contribution from the three corners
var t0 = 0.5 - x0 * x0 - y0 * y0;
if (t0 < 0) {
n0 = 0;
} else {
t0 *= t0;
n0 = t0 * t0 * gi0.dot2(x0, y0); // (x,y) of grad3 used for 2D gradient
}
var t1 = 0.5 - x1 * x1 - y1 * y1;
if (t1 < 0) {
n1 = 0;
} else {
t1 *= t1;
n1 = t1 * t1 * gi1.dot2(x1, y1);
}
var t2 = 0.5 - x2 * x2 - y2 * y2;
if (t2 < 0) {
n2 = 0;
} else {
t2 *= t2;
n2 = t2 * t2 * gi2.dot2(x2, y2);
}
// Add contributions from each corner to get the final noise value.
// The result is scaled to return values in the interval [-1,1].
return 70 * (n0 + n1 + n2);
}
}

View File

@@ -0,0 +1,258 @@
export const perlinNoiseData = [
151,
160,
137,
91,
90,
15,
131,
13,
201,
95,
96,
53,
194,
233,
7,
225,
140,
36,
103,
30,
69,
142,
8,
99,
37,
240,
21,
10,
23,
190,
6,
148,
247,
120,
234,
75,
0,
26,
197,
62,
94,
252,
219,
203,
117,
35,
11,
32,
57,
177,
33,
88,
237,
149,
56,
87,
174,
20,
125,
136,
171,
168,
68,
175,
74,
165,
71,
134,
139,
48,
27,
166,
77,
146,
158,
231,
83,
111,
229,
122,
60,
211,
133,
230,
220,
105,
92,
41,
55,
46,
245,
40,
244,
102,
143,
54,
65,
25,
63,
161,
1,
216,
80,
73,
209,
76,
132,
187,
208,
89,
18,
169,
200,
196,
135,
130,
116,
188,
159,
86,
164,
100,
109,
198,
173,
186,
3,
64,
52,
217,
226,
250,
124,
123,
5,
202,
38,
147,
118,
126,
255,
82,
85,
212,
207,
206,
59,
227,
47,
16,
58,
17,
182,
189,
28,
42,
223,
183,
170,
213,
119,
248,
152,
2,
44,
154,
163,
70,
221,
153,
101,
155,
167,
43,
172,
9,
129,
22,
39,
253,
19,
98,
108,
110,
79,
113,
224,
232,
178,
185,
112,
104,
218,
246,
97,
228,
251,
34,
242,
193,
238,
210,
144,
12,
191,
179,
162,
241,
81,
51,
145,
235,
249,
14,
239,
107,
49,
192,
214,
31,
181,
199,
106,
157,
184,
84,
204,
176,
115,
121,
50,
45,
127,
4,
150,
254,
138,
236,
205,
93,
222,
114,
67,
29,
24,
72,
243,
141,
128,
195,
78,
66,
215,
61,
156,
180,
];

69
src/js/core/polyfills.js Normal file
View File

@@ -0,0 +1,69 @@
function mathPolyfills() {
// Converts from degrees to radians.
Math.radians = function (degrees) {
return (degrees * Math_PI) / 180.0;
};
// Converts from radians to degrees.
Math.degrees = function (radians) {
return (radians * 180.0) / Math_PI;
};
}
function stringPolyfills() {
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
if (!String.prototype.padStart) {
String.prototype.padStart = function padStart(targetLength, padString) {
targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0;
padString = String(typeof padString !== "undefined" ? padString : " ");
if (this.length >= targetLength) {
return String(this);
} else {
targetLength = targetLength - this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
}
return padString.slice(0, targetLength) + String(this);
}
};
}
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padEnd
if (!String.prototype.padEnd) {
String.prototype.padEnd = function padEnd(targetLength, padString) {
targetLength = targetLength >> 0; //floor if number or convert non-number to 0;
padString = String(typeof padString !== "undefined" ? padString : " ");
if (this.length > targetLength) {
return String(this);
} else {
targetLength = targetLength - this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
}
return String(this) + padString.slice(0, targetLength);
}
};
}
}
function initPolyfills() {
mathPolyfills();
stringPolyfills();
}
function initExtensions() {
String.prototype.replaceAll = function (search, replacement) {
var target = this;
return target.split(search).join(replacement);
};
}
// Fetch polyfill
import "whatwg-fetch";
import { Math_PI } from "./builtins";
// Other polyfills
initPolyfills();
initExtensions();

View File

@@ -0,0 +1,10 @@
const queryString = require("query-string");
const options = queryString.parse(location.search);
export let queryParamOptions = {
embedProvider: null,
};
if (options.embed) {
queryParamOptions.embedProvider = options.embed;
}

View File

@@ -0,0 +1,300 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
import { sha1 } from "./sensitive_utils.encrypt";
import { createLogger } from "./logging";
import { FILE_NOT_FOUND } from "../platform/storage";
import { accessNestedPropertyReverse } from "./utils";
import { IS_DEBUG, globalConfig } from "./config";
import { JSON_stringify, JSON_parse } from "./builtins";
import { ExplainedResult } from "./explained_result";
import { decompressX64, compressX64 } from ".//lzstring";
import { asyncCompressor, compressionPrefix } from "./async_compression";
const logger = createLogger("read_write_proxy");
const salt = accessNestedPropertyReverse(globalConfig, ["file", "info"]);
// Helper which only writes / reads if verify() works. Also performs migration
export class ReadWriteProxy {
constructor(app, filename) {
/** @type {Application} */
this.app = app;
this.filename = filename;
/** @type {object} */
this.currentData = null;
// TODO: EXTREMELY HACKY! To verify we need to do this a step later
if (G_IS_DEV && IS_DEBUG) {
setTimeout(() => {
assert(
this.verify(this.getDefaultData()).result,
"Verify() failed for default data: " + this.verify(this.getDefaultData()).reason
);
});
}
}
// -- Methods to override
/** @returns {ExplainedResult} */
verify(data) {
abstract;
return ExplainedResult.bad();
}
// Should return the default data
getDefaultData() {
abstract;
return {};
}
// Should return the current version as an integer
getCurrentVersion() {
abstract;
return 0;
}
// Should migrate the data (Modify in place)
/** @returns {ExplainedResult} */
migrate(data) {
abstract;
return ExplainedResult.bad();
}
// -- / Methods
// Resets whole data, returns promise
resetEverythingAsync() {
logger.warn("Reset data to default");
this.currentData = this.getDefaultData();
return this.writeAsync();
}
getCurrentData() {
return this.currentData;
}
/**
* Writes the data asychronously, fails if verify() fails
* @returns {Promise<string>}
*/
writeAsync() {
const verifyResult = this.internalVerifyEntry(this.currentData);
if (!verifyResult.result) {
logger.error("Tried to write invalid data to", this.filename, "reason:", verifyResult.reason);
return Promise.reject(verifyResult.reason);
}
const jsonString = JSON_stringify(this.currentData);
if (!this.app.pageVisible || this.app.unloaded) {
logger.log("Saving file sync because in unload handler");
const checksum = sha1(jsonString + salt);
let compressed = compressionPrefix + compressX64(checksum + jsonString);
if (G_IS_DEV && IS_DEBUG) {
compressed = jsonString;
}
if (!this.app.storage.writeFileSyncIfSupported(this.filename, compressed)) {
return Promise.reject("Failed to write " + this.filename + " sync!");
} else {
logger.log("📄 Wrote (sync!)", this.filename);
return Promise.resolve(compressed);
}
}
return asyncCompressor
.compressFileAsync(jsonString)
.then(compressed => {
if (G_IS_DEV && IS_DEBUG) {
compressed = jsonString;
}
return this.app.storage.writeFileAsync(this.filename, compressed);
})
.then(() => {
logger.log("📄 Wrote", this.filename);
return jsonString;
})
.catch(err => {
logger.error("Failed to write", this.filename, ":", err);
throw err;
});
}
// Reads the data asynchronously, fails if verify() fails
readAsync() {
// Start read request
return (
this.app.storage
.readFileAsync(this.filename)
// Check for errors during read
.catch(err => {
if (err === FILE_NOT_FOUND) {
logger.log("File not found, using default data");
// File not found or unreadable, assume default file
return Promise.resolve(null);
}
return Promise.reject("file-error: " + err);
})
// Decrypt data (if its encrypted)
// @ts-ignore
.then(rawData => {
if (rawData == null) {
// So, the file has not been found, use default data
return JSON_stringify(this.getDefaultData());
}
if (rawData.startsWith(compressionPrefix)) {
const decompressed = decompressX64(rawData.substr(compressionPrefix.length));
if (!decompressed) {
// LZ string decompression failure
return Promise.reject("bad-content / decompression-failed");
}
if (decompressed.length < 40) {
// String too short
return Promise.reject("bad-content / payload-too-small");
}
// Compare stored checksum with actual checksum
const checksum = decompressed.substring(0, 40);
const jsonString = decompressed.substr(40);
const desiredChecksum = sha1(jsonString + salt);
if (desiredChecksum !== checksum) {
// Checksum mismatch
return Promise.reject("bad-content / checksum-mismatch");
}
return jsonString;
} else {
if (!G_IS_DEV) {
return Promise.reject("bad-content / missing-compression");
}
}
return rawData;
})
// Parse JSON, this could throw but thats fine
.then(res => {
try {
return JSON_parse(res);
} catch (ex) {
logger.error(
"Failed to parse file content of",
this.filename,
":",
ex,
"(content was:",
res,
")"
);
throw new Error("invalid-serialized-data");
}
})
// Verify basic structure
.then(contents => {
const result = this.internalVerifyBasicStructure(contents);
if (!result.isGood()) {
return Promise.reject("verify-failed: " + result.reason);
}
return contents;
})
// Check version and migrate if required
.then(contents => {
if (contents.version > this.getCurrentVersion()) {
return Promise.reject("stored-data-is-newer");
}
if (contents.version < this.getCurrentVersion()) {
logger.log(
"Trying to migrate data object from version",
contents.version,
"to",
this.getCurrentVersion()
);
const migrationResult = this.migrate(contents); // modify in place
if (migrationResult.isBad()) {
return Promise.reject("migration-failed: " + migrationResult.reason);
}
}
return contents;
})
// Verify
.then(contents => {
const verifyResult = this.internalVerifyEntry(contents);
if (!verifyResult.result) {
logger.error(
"Read invalid data from",
this.filename,
"reason:",
verifyResult.reason,
"contents:",
contents
);
return Promise.reject("invalid-data: " + verifyResult.reason);
}
return contents;
})
// Store
.then(contents => {
this.currentData = contents;
logger.log("📄 Read data with version", this.currentData.version, "from", this.filename);
return contents;
})
// Catchall
.catch(err => {
return Promise.reject("Failed to read " + this.filename + ": " + err);
})
);
}
/**
* Deletes the file
* @returns {Promise<void>}
*/
deleteAsync() {
return this.app.storage.deleteFileAsync(this.filename);
}
// Internal
/** @returns {ExplainedResult} */
internalVerifyBasicStructure(data) {
if (!data) {
return ExplainedResult.bad("Data is empty");
}
if (!Number.isInteger(data.version) || data.version < 0) {
return ExplainedResult.bad(
`Data has invalid version: ${data.version} (expected ${this.getCurrentVersion()})`
);
}
return ExplainedResult.good();
}
/** @returns {ExplainedResult} */
internalVerifyEntry(data) {
if (data.version !== this.getCurrentVersion()) {
return ExplainedResult.bad(
"Version mismatch, got " + data.version + " and expected " + this.getCurrentVersion()
);
}
const verifyStructureError = this.internalVerifyBasicStructure(data);
if (!verifyStructureError.isGood()) {
return verifyStructureError;
}
return this.verify(data);
}
}

287
src/js/core/rectangle.js Normal file
View File

@@ -0,0 +1,287 @@
import { globalConfig } from "./config";
import { Math_ceil, Math_floor, Math_max, Math_min } from "./builtins";
import { clamp, epsilonCompare, round2Digits } from "./utils";
import { Vector } from "./vector";
export class Rectangle {
constructor(x = 0, y = 0, w = 0, h = 0) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
}
/**
* Creates a rectangle from top right bottom and left offsets
* @param {number} top
* @param {number} right
* @param {number} bottom
* @param {number} left
*/
static fromTRBL(top, right, bottom, left) {
return new Rectangle(left, top, right - left, bottom - top);
}
/**
* Constructs a new square rectangle
* @param {number} x
* @param {number} y
* @param {number} size
*/
static fromSquare(x, y, size) {
return new Rectangle(x, y, size, size);
}
/**
*
* @param {Vector} p1
* @param {Vector} p2
*/
static fromTwoPoints(p1, p2) {
const left = Math_min(p1.x, p2.x);
const top = Math_min(p1.y, p2.y);
const right = Math_max(p1.x, p2.x);
const bottom = Math_max(p1.y, p2.y);
return new Rectangle(left, top, right - left, bottom - top);
}
/**
* @param {Rectangle} a
* @param {Rectangle} b
*/
static intersects(a, b) {
return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom;
}
/**
* Returns a rectangle arround a rotated point
* @param {Array<Vector>} points
* @param {number} angle
* @returns {Rectangle}
*/
static getAroundPointsRotated(points, angle) {
let minX = 1e10;
let minY = 1e10;
let maxX = -1e10;
let maxY = -1e10;
for (let i = 0; i < points.length; ++i) {
const rotated = points[i].rotated(angle);
minX = Math_min(minX, rotated.x);
minY = Math_min(minY, rotated.y);
maxX = Math_max(maxX, rotated.x);
maxY = Math_max(maxY, rotated.y);
}
return new Rectangle(minX, minY, maxX - minX, maxY - minY);
}
// Ensures the rectangle contains the given square
extendBySquare(centerX, centerY, halfWidth, halfHeight) {
if (this.isEmpty()) {
// Just assign values since this rectangle is empty
this.x = centerX - halfWidth;
this.y = centerY - halfHeight;
this.w = halfWidth * 2;
this.h = halfHeight * 2;
// console.log("Assigned", this.x, this.y, this.w, this.h);
} else {
// console.log("before", this.x, this.y, this.w, this.h);
this.setLeft(Math_min(this.x, centerX - halfWidth));
this.setRight(Math_max(this.right(), centerX + halfWidth));
this.setTop(Math_min(this.y, centerY - halfHeight));
this.setBottom(Math_max(this.bottom(), centerY + halfHeight));
// console.log("Extended", this.x, this.y, this.w, this.h);
}
}
isEmpty() {
return epsilonCompare(this.w * this.h, 0);
}
equalsEpsilon(other, epsilon) {
return (
epsilonCompare(this.x, other.x, epsilon) &&
epsilonCompare(this.y, other.y, epsilon) &&
epsilonCompare(this.w, other.w, epsilon) &&
epsilonCompare(this.h, other.h, epsilon)
);
}
left() {
return this.x;
}
right() {
return this.x + this.w;
}
top() {
return this.y;
}
bottom() {
return this.y + this.h;
}
trbl() {
return [this.y, this.right(), this.bottom(), this.x];
}
getCenter() {
return new Vector(this.x + this.w / 2, this.y + this.h / 2);
}
setRight(right) {
this.w = right - this.x;
}
setBottom(bottom) {
this.h = bottom - this.y;
}
// Sets top while keeping bottom
setTop(top) {
const bottom = this.bottom();
this.y = top;
this.setBottom(bottom);
}
// Sets left while keeping right
setLeft(left) {
const right = this.right();
this.x = left;
this.setRight(right);
}
topLeft() {
return new Vector(this.x, this.y);
}
bottomRight() {
return new Vector(this.right(), this.bottom());
}
moveBy(x, y) {
this.x += x;
this.y += y;
}
moveByVector(vec) {
this.x += vec.x;
this.y += vec.y;
}
// Returns a scaled version which also scales the position of the rectangle
allScaled(factor) {
return new Rectangle(this.x * factor, this.y * factor, this.w * factor, this.h * factor);
}
// Increases the rectangle in all directions
expandInAllDirections(amount) {
this.x -= amount;
this.y -= amount;
this.w += 2 * amount;
this.h += 2 * amount;
return this;
}
// Culling helpers
getMinStartTile() {
return new Vector(this.x, this.y).snapWorldToTile();
}
/**
* Returns if the given rectangle is contained
* @param {Rectangle} rect
* @returns {boolean}
*/
containsRect(rect) {
return (
this.x <= rect.right() &&
rect.x <= this.right() &&
this.y <= rect.bottom() &&
rect.y <= this.bottom()
);
}
containsRect4Params(x, y, w, h) {
return this.x <= x + w && x <= this.right() && this.y <= y + h && y <= this.bottom();
}
/**
* Returns if the rectangle contains the given circle at (x, y) with the radius (radius)
* @param {number} x
* @param {number} y
* @param {number} radius
*/
containsCircle(x, y, radius) {
return (
this.x <= x + radius &&
x - radius <= this.right() &&
this.y <= y + radius &&
y - radius <= this.bottom()
);
}
/**
* Returns if hte rectangle contains the given point
* @param {number} x
* @param {number} y
*/
containsPoint(x, y) {
return x >= this.x && x < this.right() && y >= this.y && y < this.bottom();
}
/**
* Returns the shared area with another rectangle, or null if there is no intersection
* @param {Rectangle} rect
* @returns {Rectangle|null}
*/
getUnion(rect) {
const left = Math_max(this.x, rect.x);
const top = Math_max(this.y, rect.y);
const right = Math_min(this.x + this.w, rect.x + rect.w);
const bottom = Math_min(this.y + this.h, rect.y + rect.h);
if (right <= left || bottom <= top) {
return null;
}
return Rectangle.fromTRBL(top, right, bottom, left);
}
/**
* Good for caching stuff
*/
toCompareableString() {
return (
round2Digits(this.x) +
"/" +
round2Digits(this.y) +
"/" +
round2Digits(this.w) +
"/" +
round2Digits(this.h)
);
}
/**
* Returns a new recangle in tile space which includes all tiles which are visible in this rect
* @param {boolean=} includeHalfTiles
* @returns {Rectangle}
*/
toTileCullRectangle(includeHalfTiles = true) {
let scaled = this.allScaled(1.0 / globalConfig.tileSize);
if (includeHalfTiles) {
// Increase rectangle size
scaled = Rectangle.fromTRBL(
Math_floor(scaled.y),
Math_ceil(scaled.right()),
Math_ceil(scaled.bottom()),
Math_floor(scaled.x)
);
}
return scaled;
}
}

View File

@@ -0,0 +1,72 @@
import { createLogger } from "../core/logging";
import { fastArrayDeleteValueIfContained } from "../core/utils";
const logger = createLogger("request_channel");
// Thrown when a request is aborted
export const PROMISE_ABORTED = "promise-aborted";
export class RequestChannel {
constructor() {
/** @type {Array<Promise>} */
this.pendingPromises = [];
}
/**
*
* @param {Promise<any>} promise
* @returns {Promise<any>}
*/
watch(promise) {
// log(this, "Added new promise:", promise, "(pending =", this.pendingPromises.length, ")");
let cancelled = false;
const wrappedPromise = new Promise((resolve, reject) => {
promise.then(
result => {
// Remove from pending promises
fastArrayDeleteValueIfContained(this.pendingPromises, wrappedPromise);
// If not cancelled, resolve promise with same payload
if (!cancelled) {
resolve.call(this, result);
} else {
logger.warn("Not resolving because promise got cancelled");
// reject.call(this, PROMISE_ABORTED);
}
},
err => {
// Remove from pending promises
fastArrayDeleteValueIfContained(this.pendingPromises, wrappedPromise);
// If not cancelled, reject promise with same payload
if (!cancelled) {
reject.call(this, err);
} else {
logger.warn("Not rejecting because promise got cancelled");
// reject.call(this, PROMISE_ABORTED);
}
}
);
});
// Add cancel handler
// @ts-ignore
wrappedPromise.cancel = function () {
cancelled = true;
};
this.pendingPromises.push(wrappedPromise);
return wrappedPromise;
}
cancelAll() {
if (this.pendingPromises.length > 0) {
logger.log("Cancel all pending promises (", this.pendingPromises.length, ")");
}
for (let i = 0; i < this.pendingPromises.length; ++i) {
// @ts-ignore
this.pendingPromises[i].cancel();
}
this.pendingPromises = [];
}
}

133
src/js/core/rng.js Normal file
View File

@@ -0,0 +1,133 @@
import { Math_random } from "./builtins";
// ALEA RNG
function Mash() {
var n = 0xefc8249d;
return function (data) {
data = data.toString();
for (var i = 0; i < data.length; i++) {
n += data.charCodeAt(i);
var h = 0.02519603282416938 * n;
n = h >>> 0;
h -= n;
h *= n;
n = h >>> 0;
h -= n;
n += h * 0x100000000; // 2^32
}
return (n >>> 0) * 2.3283064365386963e-10; // 2^-32
};
}
/**
* @param {number|string} seed
*/
function makeNewRng(seed) {
// Johannes Baagøe <baagoe@baagoe.com>, 2010
var c = 1;
var mash = Mash();
let s0 = mash(" ");
let s1 = mash(" ");
let s2 = mash(" ");
s0 -= mash(seed);
if (s0 < 0) {
s0 += 1;
}
s1 -= mash(seed);
if (s1 < 0) {
s1 += 1;
}
s2 -= mash(seed);
if (s2 < 0) {
s2 += 1;
}
mash = null;
var random = function () {
var t = 2091639 * s0 + c * 2.3283064365386963e-10; // 2^-32
s0 = s1;
s1 = s2;
return (s2 = t - (c = t | 0));
};
random.exportState = function () {
return [s0, s1, s2, c];
};
random.importState = function (i) {
s0 = +i[0] || 0;
s1 = +i[1] || 0;
s2 = +i[2] || 0;
c = +i[3] || 0;
};
return random;
}
export class RandomNumberGenerator {
/**
*
* @param {number|string=} seed
*/
constructor(seed) {
this.internalRng = makeNewRng(seed || Math_random());
}
/**
* Re-seeds the generator
* @param {number|string} seed
*/
reseed(seed) {
this.internalRng = makeNewRng(seed || Math_random());
}
/**
* @returns {number} between 0 and 1
*/
next() {
return this.internalRng();
}
/**
* @param {number} min
* @param {number} max
* @returns {number} Integer in range [min, max[
*/
nextIntRange(min, max) {
assert(Number.isFinite(min), "Minimum is no integer");
assert(Number.isFinite(max), "Maximum is no integer");
assert(max > min, "rng: max <= min");
return Math.floor(this.next() * (max - min) + min);
}
/**
* @param {number} min
* @param {number} max
* @returns {number} Integer in range [min, max]
*/
nextIntRangeInclusive(min, max) {
assert(Number.isFinite(min), "Minimum is no integer");
assert(Number.isFinite(max), "Maximum is no integer");
assert(max > min, "rng: max <= min");
return Math.round(this.next() * (max - min) + min);
}
/**
* @param {number} min
* @param {number} max
* @returns {number} Number in range [min, max[
*/
nextRange(min, max) {
assert(max > min, "rng: max <= min");
return this.next() * (max - min) + min;
}
/**
* Updates the seed
* @param {number} seed
*/
setSeed(seed) {
this.internalRng = makeNewRng(seed);
}
}

View File

@@ -0,0 +1,62 @@
import { globalConfig } from "./config";
import { decompressX64, compressX64 } from "./lzstring";
const Rusha = require("rusha");
const encryptKey = globalConfig.info.sgSalt;
export function decodeHashedString(s) {
return decompressX64(s);
}
export function sha1(str) {
return Rusha.createHash().update(str).digest("hex");
}
// Window.location.host
export function getNameOfProvider() {
return window[decodeHashedString("DYewxghgLgliB2Q")][decodeHashedString("BYewzgLgdghgtgUyA")];
}
export function compressWithChecksum(object) {
const stringified = JSON.stringify(object);
const checksum = Rusha.createHash()
.update(stringified + encryptKey)
.digest("hex");
return compressX64(checksum + stringified);
}
export function decompressWithChecksum(binary) {
let decompressed = null;
try {
decompressed = decompressX64(binary);
} catch (err) {
throw new Error("failed-to-decompress");
}
// Split into checksum and content
if (!decompressed || decompressed.length < 41) {
throw new Error("checksum-missing");
}
const checksum = decompressed.substr(0, 40);
const rawData = decompressed.substr(40);
// Validate checksum
const computedChecksum = Rusha.createHash()
.update(rawData + encryptKey)
.digest("hex");
if (computedChecksum !== checksum) {
throw new Error("checksum-mismatch");
}
// Try parsing the JSON
let data = null;
try {
data = JSON.parse(rawData);
} catch (err) {
throw new Error("failed-to-parse");
}
return data;
}

66
src/js/core/signal.js Normal file
View File

@@ -0,0 +1,66 @@
export const STOP_PROPAGATION = "stop_propagation";
export class Signal {
constructor() {
this.receivers = [];
this.modifyCount = 0;
}
/**
* Adds a new signal listener
* @param {object} receiver
* @param {object} scope
*/
add(receiver, scope = null) {
assert(receiver, "receiver is null");
this.receivers.push({ receiver, scope });
++this.modifyCount;
}
/**
* Dispatches the signal
* @param {...any} payload
*/
dispatch() {
const modifyState = this.modifyCount;
const n = this.receivers.length;
for (let i = 0; i < n; ++i) {
const { receiver, scope } = this.receivers[i];
if (receiver.apply(scope, arguments) === STOP_PROPAGATION) {
return STOP_PROPAGATION;
}
if (modifyState !== this.modifyCount) {
// Signal got modified during iteration
return STOP_PROPAGATION;
}
}
}
/**
* Removes a receiver
* @param {object} receiver
*/
remove(receiver) {
let index = null;
const n = this.receivers.length;
for (let i = 0; i < n; ++i) {
if (this.receivers[i].receiver === receiver) {
index = i;
break;
}
}
assert(index !== null, "Receiver not found in list");
this.receivers.splice(index, 1);
++this.modifyCount;
}
/**
* Removes all receivers
*/
removeAll() {
this.receivers = [];
++this.modifyCount;
}
}

View File

@@ -0,0 +1,78 @@
// simple factory pattern
export class SingletonFactory {
constructor() {
// Store array as well as dictionary, to speed up lookups
this.entries = [];
this.idToEntry = {};
}
register(classHandle) {
// First, construct instance
const instance = new classHandle();
// Extract id
const id = instance.getId();
assert(id, "Factory: Invalid id for class " + classHandle.name + ": " + id);
// Check duplicates
assert(!this.idToEntry[id], "Duplicate factory entry for " + id);
// Insert
this.entries.push(instance);
this.idToEntry[id] = instance;
}
/**
* Checks if a given id is registered
* @param {string} id
* @returns {boolean}
*/
hasId(id) {
return !!this.idToEntry[id];
}
/**
* Finds an instance by a given id
* @param {string} id
* @returns {object}
*/
findById(id) {
const entry = this.idToEntry[id];
if (!entry) {
assert(false, "Factory: Object with id '" + id + "' is not registered!");
return null;
}
return entry;
}
/**
* Finds an instance by its constructor (The class handle)
* @param {object} classHandle
* @returns {object}
*/
findByClass(classHandle) {
for (let i = 0; i < this.entries.length; ++i) {
if (this.entries[i] instanceof classHandle) {
return this.entries[i];
}
}
assert(false, "Factory: Object not found by classHandle (classid: " + classHandle.name + ")");
return null;
}
/**
* Returns all entries
* @returns {Array<object>}
*/
getEntries() {
return this.entries;
}
/**
* Returns amount of stored entries
* @returns {number}
*/
getNumEntries() {
return this.entries.length;
}
}

351
src/js/core/sprites.js Normal file
View File

@@ -0,0 +1,351 @@
import { DrawParameters } from "./draw_parameters";
import { Math_floor } from "./builtins";
import { Rectangle } from "./rectangle";
import { epsilonCompare, round3Digits } from "./utils";
const floorSpriteCoordinates = false;
const ORIGINAL_SCALE = "1";
export class BaseSprite {
/**
* Returns the raw handle
* @returns {HTMLImageElement|HTMLCanvasElement}
*/
getRawTexture() {
abstract;
return null;
}
/**
* Draws the sprite
* @param {CanvasRenderingContext2D} context
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
*/
draw(context, x, y, w, h) {
// eslint-disable-line no-unused-vars
abstract;
}
}
/**
* Position of a sprite within an atlas
*/
export class SpriteAtlasLink {
/**
*
* @param {object} param0
* @param {number} param0.packedX
* @param {number} param0.packedY
* @param {number} param0.packOffsetX
* @param {number} param0.packOffsetY
* @param {number} param0.packedW
* @param {number} param0.packedH
* @param {number} param0.w
* @param {number} param0.h
* @param {HTMLImageElement|HTMLCanvasElement} param0.atlas
*/
constructor({ w, h, packedX, packedY, packOffsetX, packOffsetY, packedW, packedH, atlas }) {
this.packedX = packedX;
this.packedY = packedY;
this.packedW = packedW;
this.packedH = packedH;
this.packOffsetX = packOffsetX;
this.packOffsetY = packOffsetY;
this.atlas = atlas;
this.w = w;
this.h = h;
}
}
export class AtlasSprite extends BaseSprite {
/**
*
* @param {object} param0
* @param {string} param0.spriteName
*/
constructor({ spriteName = "sprite" }) {
super();
/** @type {Object.<string, SpriteAtlasLink>} */
this.linksByResolution = {};
this.spriteName = spriteName;
}
getRawTexture() {
return this.linksByResolution[ORIGINAL_SCALE].atlas;
}
/**
* Draws the sprite onto a regular context using no contexts
* @see {BaseSprite.draw}
*/
draw(context, x, y, w, h) {
if (G_IS_DEV) {
assert(context instanceof CanvasRenderingContext2D, "Not a valid context");
}
console.warn("drawing sprite regulary");
const link = this.linksByResolution[ORIGINAL_SCALE];
const width = w || link.w;
const height = h || link.h;
const scaleW = width / link.w;
const scaleH = height / link.h;
context.drawImage(
link.atlas,
link.packedX,
link.packedY,
link.packedW,
link.packedH,
x + link.packOffsetX * scaleW,
y + link.packOffsetY * scaleH,
link.packedW * scaleW,
link.packedH * scaleH
);
}
/**
*
* @param {DrawParameters} parameters
* @param {number} x
* @param {number} y
* @param {number} size
* @param {boolean=} clipping
*/
drawCachedCentered(parameters, x, y, size, clipping = true) {
this.drawCached(parameters, x - size / 2, y - size / 2, size, size, clipping);
}
/**
*
* @param {CanvasRenderingContext2D} context
* @param {number} x
* @param {number} y
* @param {number} size
*/
drawCentered(context, x, y, size) {
this.draw(context, x - size / 2, y - size / 2, size, size);
}
/**
* Draws the sprite
* @param {DrawParameters} parameters
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
* @param {boolean=} clipping Whether to perform culling
*/
drawCached(parameters, x, y, w = null, h = null, clipping = true) {
if (G_IS_DEV) {
assertAlways(parameters instanceof DrawParameters, "Not a valid context");
assertAlways(!!w && w > 0, "Not a valid width:" + w);
assertAlways(!!h && h > 0, "Not a valid height:" + h);
}
const visibleRect = parameters.visibleRect;
const scale = parameters.desiredAtlasScale;
const link = this.linksByResolution[scale];
const scaleW = w / link.w;
const scaleH = h / link.h;
let destX = x + link.packOffsetX * scaleW;
let destY = y + link.packOffsetY * scaleH;
let destW = link.packedW * scaleW;
let destH = link.packedH * scaleH;
let srcX = link.packedX;
let srcY = link.packedY;
let srcW = link.packedW;
let srcH = link.packedH;
let intersection = null;
if (clipping) {
const rect = new Rectangle(destX, destY, destW, destH);
intersection = rect.getUnion(visibleRect);
if (!intersection) {
return;
}
srcX += (intersection.x - destX) / scaleW;
srcY += (intersection.y - destY) / scaleH;
srcW *= intersection.w / destW;
srcH *= intersection.h / destH;
destX = intersection.x;
destY = intersection.y;
destW = intersection.w;
destH = intersection.h;
}
// assert(epsilonCompare(scaleW, scaleH), "Sprite should be square for cached rendering");
if (floorSpriteCoordinates) {
parameters.context.drawImage(
link.atlas,
// atlas src pos
Math_floor(srcX),
Math_floor(srcY),
// atlas src size
Math_floor(srcW),
Math_floor(srcH),
// dest pos
Math_floor(destX),
Math_floor(destY),
// dest size
Math_floor(destW),
Math_floor(destH)
);
} else {
parameters.context.drawImage(
link.atlas,
// atlas src pos
srcX,
srcY,
// atlas src siize
srcW,
srcH,
// dest pos and size
destX,
destY,
destW,
destH
);
}
}
/**
* Renders into an html element
* @param {HTMLElement} element
* @param {number} w
* @param {number} h
*/
renderToHTMLElement(element, w = 1, h = 1) {
element.style.position = "relative";
element.innerHTML = this.getAsHTML(w, h);
}
/**
* Returns the html to render as icon
* @param {number} w
* @param {number} h
*/
getAsHTML(w, h) {
const link = this.linksByResolution["0.5"];
// Find out how much we have to scale it so that it fits
const scaleX = w / link.w;
const scaleY = h / link.h;
// Find out how big the scaled atlas is
const atlasW = link.atlas.width * scaleX;
const atlasH = link.atlas.height * scaleY;
// @ts-ignore
const srcSafe = link.atlas.src.replaceAll("\\", "/");
// Find out how big we render the sprite
const widthAbsolute = scaleX * link.packedW;
const heightAbsolute = scaleY * link.packedH;
// Compute the position in the relative container
const leftRelative = (link.packOffsetX * scaleX) / w;
const topRelative = (link.packOffsetY * scaleY) / h;
const widthRelative = widthAbsolute / w;
const heightRelative = heightAbsolute / h;
// Scale the atlas relative to the width and height of the element
const bgW = atlasW / widthAbsolute;
const bgH = atlasH / heightAbsolute;
// Figure out what the position of the atlas is
const bgX = link.packedX * scaleX;
const bgY = link.packedY * scaleY;
// Fuck you, whoever thought its a good idea to make background-position work like it does now
const bgXRelative = -bgX / (widthAbsolute - atlasW);
const bgYRelative = -bgY / (heightAbsolute - atlasH);
return `
<span class="spritesheetImage" style="
background-image: url('${srcSafe}');
left: ${round3Digits(leftRelative * 100.0)}%;
top: ${round3Digits(topRelative * 100.0)}%;
width: ${round3Digits(widthRelative * 100.0)}%;
height: ${round3Digits(heightRelative * 100.0)}%;
background-repeat: repeat;
background-position: ${round3Digits(bgXRelative * 100.0)}% ${round3Digits(
bgYRelative * 100.0
)}%;
background-size: ${round3Digits(bgW * 100.0)}% ${round3Digits(bgH * 100.0)}%;
"></span>
`;
}
}
export class RegularSprite extends BaseSprite {
constructor(sprite, w, h) {
super();
this.w = w;
this.h = h;
this.sprite = sprite;
}
getRawTexture() {
return this.sprite;
}
/**
* Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing
* images into buffers
* @param {CanvasRenderingContext2D} context
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
*/
draw(context, x, y, w, h) {
assert(context, "No context given");
assert(x !== undefined, "No x given");
assert(y !== undefined, "No y given");
assert(w !== undefined, "No width given");
assert(h !== undefined, "No height given");
context.drawImage(this.sprite, x, y, w, h);
}
/**
* Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing
* images into buffers
* @param {CanvasRenderingContext2D} context
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
*/
drawCentered(context, x, y, w, h) {
assert(context, "No context given");
assert(x !== undefined, "No x given");
assert(y !== undefined, "No y given");
assert(w !== undefined, "No width given");
assert(h !== undefined, "No height given");
context.drawImage(this.sprite, x - w / 2, y - h / 2, w, h);
}
}

View File

@@ -0,0 +1,121 @@
/* typehints:start*/
import { Application } from "../application";
/* typehints:end*/
import { GameState } from "./game_state";
import { createLogger } from "./logging";
import { APPLICATION_ERROR_OCCURED } from "./error_handler";
import { waitNextFrame, removeAllChildren } from "./utils";
const logger = createLogger("state_manager");
/**
* This is the main state machine which drives the game states.
*/
export class StateManager {
/**
* @param {Application} app
*/
constructor(app) {
this.app = app;
/** @type {GameState} */
this.currentState = null;
/** @type {Object.<string, new() => GameState>} */
this.stateClasses = {};
}
/**
* Registers a new state class, should be a GameState derived class
* @param {object} stateClass
*/
register(stateClass) {
// Create a dummy to retrieve the key
const dummy = new stateClass();
assert(dummy instanceof GameState, "Not a state!");
const key = dummy.getKey();
assert(!this.stateClasses[key], `State '${key}' is already registered!`);
this.stateClasses[key] = stateClass;
}
/**
* Constructs a new state or returns the instance from the cache
* @param {string} key
*/
constructState(key) {
if (this.stateClasses[key]) {
return new this.stateClasses[key]();
}
assert(false, `State '${key}' is not known!`);
}
/**
* Moves to a given state
* @param {string} key State Key
*/
moveToState(key, payload = {}) {
if (APPLICATION_ERROR_OCCURED) {
console.warn("Skipping state transition because of application crash");
return;
}
if (this.currentState) {
if (key === this.currentState.getKey()) {
logger.error(`State '${key}' is already active!`);
return false;
}
this.currentState.internalLeaveCallback();
// Remove all references
for (const stateKey in this.currentState) {
if (this.currentState.hasOwnProperty(stateKey)) {
delete this.currentState[stateKey];
}
}
this.currentState = null;
}
this.currentState = this.constructState(key);
this.currentState.internalRegisterCallback(this, this.app);
// Clean up old elements
removeAllChildren(document.body);
document.body.className = "gameState " + (this.currentState.getHasFadeIn() ? "" : "arrived");
document.body.id = "state_" + key;
document.body.innerHTML = this.currentState.internalGetFullHtml();
const dialogParent = document.createElement("div");
dialogParent.classList.add("modalDialogParent");
document.body.appendChild(dialogParent);
this.app.sound.playThemeMusic(this.currentState.getThemeMusic());
this.currentState.internalEnterCallback(payload);
this.currentState.onResized(this.app.screenWidth, this.app.screenHeight);
this.app.analytics.trackStateEnter(key);
window.history.pushState(
{
key,
},
key
);
waitNextFrame().then(() => {
document.body.classList.add("arrived");
});
return true;
}
/**
* Returns the current state
* @returns {GameState}
*/
getCurrentState() {
return this.currentState;
}
}

View File

@@ -0,0 +1,39 @@
export class TrackedState {
constructor(callbackMethod = null, callbackScope = null) {
this.lastSeenValue = null;
if (callbackMethod) {
this.callback = callbackMethod;
if (callbackScope) {
this.callback = this.callback.bind(callbackScope);
}
}
}
set(value, changeHandler = null, changeScope = null) {
if (value !== this.lastSeenValue) {
// Copy value since the changeHandler call could actually modify our lastSeenValue
const valueCopy = value;
this.lastSeenValue = value;
if (changeHandler) {
if (changeScope) {
changeHandler.call(changeScope, valueCopy);
} else {
changeHandler(valueCopy);
}
} else if (this.callback) {
this.callback(value);
} else {
assert(false, "No callback specified");
}
}
}
setSilent(value) {
this.lastSeenValue = value;
}
get() {
return this.lastSeenValue;
}
}

889
src/js/core/utils.js Normal file
View File

@@ -0,0 +1,889 @@
import { globalConfig, IS_DEBUG } from "./config";
import {
Math_abs,
Math_atan2,
Math_ceil,
Math_floor,
Math_log10,
Math_max,
Math_min,
Math_PI,
Math_pow,
Math_random,
Math_round,
Math_sin,
performanceNow,
} from "./builtins";
import { Vector } from "./vector";
// Constants
export const TOP = new Vector(0, -1);
export const RIGHT = new Vector(1, 0);
export const BOTTOM = new Vector(0, 1);
export const LEFT = new Vector(-1, 0);
export const ALL_DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT];
export const thousand = 1000;
export const million = 1000 * 1000;
export const billion = 1000 * 1000 * 1000;
/**
* Returns the build id
* @returns {string}
*/
export function getBuildId() {
if (G_IS_DEV && IS_DEBUG) {
return "local-dev";
} else if (G_IS_DEV) {
return "dev-" + getPlatformName() + "-" + G_BUILD_COMMIT_HASH;
} else {
return "prod-" + getPlatformName() + "-" + G_BUILD_COMMIT_HASH;
}
}
/**
* Returns the environment id (dev, prod, etc)
* @returns {string}
*/
export function getEnvironmentId() {
if (G_IS_DEV && IS_DEBUG) {
return "local-dev";
} else if (G_IS_DEV) {
return "dev-" + getPlatformName();
} else if (G_IS_RELEASE) {
return "release-" + getPlatformName();
} else {
return "staging-" + getPlatformName();
}
}
/**
* Returns if this platform is android
* @returns {boolean}
*/
export function isAndroid() {
if (!G_IS_MOBILE_APP) {
return false;
}
const platform = window.device.platform;
return platform === "Android" || platform === "amazon-fireos";
}
/**
* Returns if this platform is iOs
* @returns {boolean}
*/
export function isIos() {
if (!G_IS_MOBILE_APP) {
return false;
}
return window.device.platform === "iOS";
}
/**
* Returns a platform name
* @returns {string}
*/
export function getPlatformName() {
if (G_IS_STANDALONE) {
return "standalone";
} else if (G_IS_BROWSER) {
return "browser";
} else if (G_IS_MOBILE_APP && isAndroid()) {
return "android";
} else if (G_IS_MOBILE_APP && isIos()) {
return "ios";
}
return "unknown";
}
/**
* Returns the IPC renderer, or null if not within the standalone
* @returns {object|null}
*/
let ipcRenderer = null;
export function getIPCRenderer() {
if (!G_IS_STANDALONE) {
return null;
}
if (!ipcRenderer) {
ipcRenderer = eval("require")("electron").ipcRenderer;
}
return ipcRenderer;
}
/**
* Formats a sensitive token by only displaying the first digits of it. Use for
* stuff like savegame keys etc which should not appear in the log.
* @param {string} key
*/
export function formatSensitive(key) {
if (!key) {
return "<null>";
}
key = key || "";
return "[" + key.substr(0, 8) + "...]";
}
/**
* Creates a new 2D array with the given fill method
* @param {number} w Width
* @param {number} h Height
* @param {(function(number, number) : any) | number | boolean | string | null | undefined} filler Either Fill method, which should return the content for each cell, or static content
* @param {string=} context Optional context for memory tracking
* @returns {Array<Array<any>>}
*/
export function make2DArray(w, h, filler, context = null) {
if (typeof filler === "function") {
const tiles = new Array(w);
for (let x = 0; x < w; ++x) {
const row = new Array(h);
for (let y = 0; y < h; ++y) {
row[y] = filler(x, y);
}
tiles[x] = row;
}
return tiles;
} else {
const tiles = new Array(w);
const row = new Array(h);
for (let y = 0; y < h; ++y) {
row[y] = filler;
}
for (let x = 0; x < w; ++x) {
tiles[x] = row.slice();
}
return tiles;
}
}
/**
* Makes a new 2D array with undefined contents
* @param {number} w
* @param {number} h
* @param {string=} context
* @returns {Array<Array<any>>}
*/
export function make2DUndefinedArray(w, h, context = null) {
const result = new Array(w);
for (let x = 0; x < w; ++x) {
result[x] = new Array(h);
}
return result;
}
/**
* Clears a given 2D array with the given fill method
* @param {Array<Array<any>>} array
* @param {number} w Width
* @param {number} h Height
* @param {(function(number, number) : any) | number | boolean | string | null | undefined} filler Either Fill method, which should return the content for each cell, or static content
*/
export function clear2DArray(array, w, h, filler) {
assert(array.length === w, "Array dims mismatch w");
assert(array[0].length === h, "Array dims mismatch h");
if (typeof filler === "function") {
for (let x = 0; x < w; ++x) {
const row = array[x];
for (let y = 0; y < h; ++y) {
row[y] = filler(x, y);
}
}
} else {
for (let x = 0; x < w; ++x) {
const row = array[x];
for (let y = 0; y < h; ++y) {
row[y] = filler;
}
}
}
}
/**
* Creates a new map (an empty object without any props)
*/
export function newEmptyMap() {
return Object.create(null);
}
/**
* Returns a random integer in the range [start,end]
* @param {number} start
* @param {number} end
*/
export function randomInt(start, end) {
return start + Math_round(Math_random() * (end - start));
}
/**
* Access an object in a very annoying way, used for obsfuscation.
* @param {any} obj
* @param {Array<string>} keys
*/
export function accessNestedPropertyReverse(obj, keys) {
let result = obj;
for (let i = keys.length - 1; i >= 0; --i) {
result = result[keys[i]];
}
return result;
}
/**
* Chooses a random entry of an array
* @param {Array | string} arr
*/
export function randomChoice(arr) {
return arr[Math_floor(Math_random() * arr.length)];
}
/**
* Deletes from an array by swapping with the last element
* @param {Array<any>} array
* @param {number} index
*/
export function fastArrayDelete(array, index) {
if (index < 0 || index >= array.length) {
throw new Error("Out of bounds");
}
// When the element is not the last element
if (index !== array.length - 1) {
// Get the last element, and swap it with the one we want to delete
const last = array[array.length - 1];
array[index] = last;
}
// Finally remove the last element
array.length -= 1;
}
/**
* Deletes from an array by swapping with the last element. Searches
* for the value in the array first
* @param {Array<any>} array
* @param {any} value
*/
export function fastArrayDeleteValue(array, value) {
if (array == null) {
throw new Error("Tried to delete from non array!");
}
const index = array.indexOf(value);
if (index < 0) {
console.error("Value", value, "not contained in array:", array, "!");
return value;
}
return fastArrayDelete(array, index);
}
/**
* @see fastArrayDeleteValue
* @param {Array<any>} array
* @param {any} value
*/
export function fastArrayDeleteValueIfContained(array, value) {
if (array == null) {
throw new Error("Tried to delete from non array!");
}
const index = array.indexOf(value);
if (index < 0) {
return value;
}
return fastArrayDelete(array, index);
}
/**
* Deletes from an array at the given index
* @param {Array<any>} array
* @param {number} index
*/
export function arrayDelete(array, index) {
if (index < 0 || index >= array.length) {
throw new Error("Out of bounds");
}
array.splice(index, 1);
}
/**
* Deletes the given value from an array
* @param {Array<any>} array
* @param {any} value
*/
export function arrayDeleteValue(array, value) {
if (array == null) {
throw new Error("Tried to delete from non array!");
}
const index = array.indexOf(value);
if (index < 0) {
console.error("Value", value, "not contained in array:", array, "!");
return value;
}
return arrayDelete(array, index);
}
// Converts a direction into a 0 .. 7 index
/**
* Converts a direction into a index from 0 .. 7, used for miners, zombies etc which have 8 sprites
* @param {Vector} offset direction
* @param {boolean} inverse if inverse, the direction is reversed
* @returns {number} in range [0, 7]
*/
export function angleToSpriteIndex(offset, inverse = false) {
const twoPi = 2.0 * Math_PI;
const factor = inverse ? -1 : 1;
const offs = inverse ? 2.5 : 3.5;
const angle = (factor * Math_atan2(offset.y, offset.x) + offs * Math_PI) % twoPi;
const index = Math_round((angle / twoPi) * 8) % 8;
return index;
}
/**
* Compare two floats for epsilon equality
* @param {number} a
* @param {number} b
* @returns {boolean}
*/
export function epsilonCompare(a, b, epsilon = 1e-5) {
return Math_abs(a - b) < epsilon;
}
/**
* Compare a float for epsilon equal to 0
* @param {number} a
* @returns {boolean}
*/
export function epsilonIsZero(a) {
return epsilonCompare(a, 0);
}
/**
* Interpolates two numbers
* @param {number} a
* @param {number} b
* @param {number} x Mix factor, 0 means 100% a, 1 means 100%b, rest is interpolated
*/
export function lerp(a, b, x) {
return a * (1 - x) + b * x;
}
/**
* Finds a value which is nice to display, e.g. 15669 -> 15000. Also handles fractional stuff
* @param {number} num
*/
export function findNiceValue(num) {
if (num > 1e8) {
return num;
}
if (num < 0.00001) {
return 0;
}
const roundAmount = 0.5 * Math_pow(10, Math_floor(Math_log10(num) - 1));
const niceValue = Math_floor(num / roundAmount) * roundAmount;
if (num >= 10) {
return Math_round(niceValue);
}
if (num >= 1) {
return Math_round(niceValue * 10) / 10;
}
return Math_round(niceValue * 100) / 100;
}
/**
* Finds a nice integer value
* @see findNiceValue
* @param {number} num
*/
export function findNiceIntegerValue(num) {
return Math_ceil(findNiceValue(num));
}
/**
* Smart rounding + fractional handling
* @param {number} n
*/
function roundSmart(n) {
if (n < 100) {
return n.toFixed(1);
}
return Math_round(n);
}
/**
* Formats a big number
* @param {number} num
* @param {string=} divider THe divider for numbers like 50,000 (divider=',')
* @returns {string}
*/
export function formatBigNumber(num, divider = ".") {
const sign = num < 0 ? "-" : "";
num = Math_abs(num);
if (num > 1e54) {
return sign + "inf";
}
if (num < 10 && !Number.isInteger(num)) {
return sign + num.toFixed(2);
}
if (num < 50 && !Number.isInteger(num)) {
return sign + num.toFixed(1);
}
num = Math_floor(num);
if (num < 1000) {
return sign + "" + num;
}
// if (num > 1e51) return sign + T.common.number_format.sedecillion.replace("%amount%", "" + roundSmart(num / 1e51));
// if (num > 1e48)
// return sign + T.common.number_format.quinquadecillion.replace("%amount%", "" + roundSmart(num / 1e48));
// if (num > 1e45)
// return sign + T.common.number_format.quattuordecillion.replace("%amount%", "" + roundSmart(num / 1e45));
// if (num > 1e42) return sign + T.common.number_format.tredecillion.replace("%amount%", "" + roundSmart(num / 1e42));
// if (num > 1e39) return sign + T.common.number_format.duodecillions.replace("%amount%", "" + roundSmart(num / 1e39));
// if (num > 1e36) return sign + T.common.number_format.undecillions.replace("%amount%", "" + roundSmart(num / 1e36));
// if (num > 1e33) return sign + T.common.number_format.decillions.replace("%amount%", "" + roundSmart(num / 1e33));
// if (num > 1e30) return sign + T.common.number_format.nonillions.replace("%amount%", "" + roundSmart(num / 1e30));
// if (num > 1e27) return sign + T.common.number_format.octillions.replace("%amount%", "" + roundSmart(num / 1e27));
// if (num >= 1e24) return sign + T.common.number_format.septillions.replace("%amount%", "" + roundSmart(num / 1e24));
// if (num >= 1e21) return sign + T.common.number_format.sextillions.replace("%amount%", "" + roundSmart(num / 1e21));
// if (num >= 1e18) return sign + T.common.number_format.quintillions.replace("%amount%", "" + roundSmart(num / 1e18));
// if (num >= 1e15) return sign + T.common.number_format.quantillions.replace("%amount%", "" + roundSmart(num / 1e15));
// if (num >= 1e12) return sign + T.common.number_format.trillions.replace("%amount%", "" + roundSmart(num / 1e12));
// if (num >= 1e9) return sign + T.common.number_format.billions.replace("%amount%", "" + roundSmart(num / 1e9));
// if (num >= 1e6) return sign + T.common.number_format.millions.replace("%amount%", "" + roundSmart(num / 1e6));
// if (num > 99999) return sign + T.common.number_format.thousands.replace("%amount%", "" + roundSmart(num / 1e3));
let rest = num;
let out = "";
while (rest >= 1000) {
out = (rest % 1000).toString().padStart(3, "0") + (out !== "" ? divider : "") + out;
rest = Math_floor(rest / 1000);
}
out = rest + divider + out;
return sign + out;
}
/**
* Formats a big number, but does not add any suffix and instead uses its full representation
* @param {number} num
* @param {string=} divider THe divider for numbers like 50,000 (divider=',')
* @returns {string}
*/
export function formatBigNumberFull(num, divider = T.common.number_format.divider_thousands || " ") {
if (num < 1000) {
return num + "";
}
if (num > 1e54) {
return "infinite";
}
let rest = num;
let out = "";
while (rest >= 1000) {
out = (rest % 1000).toString().padStart(3, "0") + divider + out;
rest = Math_floor(rest / 1000);
}
out = rest + divider + out;
return out.substring(0, out.length - 1);
}
/**
* Formats an amount of seconds into something like "5s ago"
* @param {number} secs Seconds
* @returns {string}
*/
export function formatSecondsToTimeAgo(secs) {
const seconds = Math_floor(secs);
const minutes = Math_floor(seconds / 60);
const hours = Math_floor(minutes / 60);
const days = Math_floor(hours / 24);
const trans = T.common.time;
if (seconds <= 60) {
if (seconds <= 1) {
return trans.one_second_before;
}
return trans.seconds_before.replace("%amount%", "" + seconds);
} else if (minutes <= 60) {
if (minutes <= 1) {
return trans.one_minute_before;
}
return trans.minutes_before.replace("%amount%", "" + minutes);
} else if (hours <= 60) {
if (hours <= 1) {
return trans.one_hour_before;
}
return trans.hours_before.replace("%amount%", "" + hours);
} else {
if (days <= 1) {
return trans.one_day_before;
}
return trans.days_before.replace("%amount%", "" + days);
}
}
/**
* Formats seconds into a readable string like "5h 23m"
* @param {number} secs Seconds
* @returns {string}
*/
export function formatSeconds(secs) {
const trans = T.common.time;
secs = Math_ceil(secs);
if (secs < 60) {
return trans.seconds_short.replace("%seconds%", "" + secs);
} else if (secs < 60 * 60) {
const minutes = Math_floor(secs / 60);
const seconds = secs % 60;
return trans.minutes_seconds_short
.replace("%seconds%", "" + seconds)
.replace("%minutes%", "" + minutes);
} else {
const hours = Math_floor(secs / 3600);
const minutes = Math_floor(secs / 60) % 60;
return trans.hours_minutes_short.replace("%minutes%", "" + minutes).replace("%hours%", "" + hours);
}
}
/**
* Delayes a promise so that it will resolve after a *minimum* amount of time only
* @param {Promise<any>} promise The promise to delay
* @param {number} minTimeMs The time to make it run at least
* @returns {Promise<any>} The delayed promise
*/
export function artificialDelayedPromise(promise, minTimeMs = 500) {
if (G_IS_DEV && globalConfig.debug.noArtificialDelays) {
return promise;
}
const startTime = performanceNow();
return promise.then(
result => {
const timeTaken = performanceNow() - startTime;
const waitTime = Math_floor(minTimeMs - timeTaken);
if (waitTime > 0) {
return new Promise(resolve => {
setTimeout(() => {
resolve(result);
}, waitTime);
});
} else {
return result;
}
},
error => {
const timeTaken = performanceNow() - startTime;
const waitTime = Math_floor(minTimeMs - timeTaken);
if (waitTime > 0) {
// @ts-ignore
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(error);
}, waitTime);
});
} else {
throw error;
}
}
);
}
/**
* Computes a sine-based animation which pulsates from 0 .. 1 .. 0
* @param {number} time Current time in seconds
* @param {number} duration Duration of the full pulse in seconds
* @param {number} seed Seed to offset the animation
*/
export function pulseAnimation(time, duration = 1.0, seed = 0.0) {
return Math_sin((time * Math_PI * 2.0) / duration + seed * 5642.86729349) * 0.5 + 0.5;
}
/**
* Returns the smallest angle between two angles
* @param {number} a
* @param {number} b
* @returns {number} 0 .. 2 PI
*/
export function smallestAngle(a, b) {
return safeMod(a - b + Math_PI, 2.0 * Math_PI) - Math_PI;
}
/**
* Modulo which works for negative numbers
* @param {number} n
* @param {number} m
*/
export function safeMod(n, m) {
return ((n % m) + m) % m;
}
/**
* Wraps an angle between 0 and 2 pi
* @param {number} angle
*/
export function wrapAngle(angle) {
return safeMod(angle, 2.0 * Math_PI);
}
/**
* Waits two frames so the ui is updated
* @returns {Promise<void>}
*/
export function waitNextFrame() {
return new Promise(function (resolve, reject) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(function () {
resolve();
});
});
});
}
/**
* Rounds 1 digit
* @param {number} n
* @returns {number}
*/
export function round1Digit(n) {
return Math_floor(n * 10.0) / 10.0;
}
/**
* Rounds 2 digits
* @param {number} n
* @returns {number}
*/
export function round2Digits(n) {
return Math_floor(n * 100.0) / 100.0;
}
/**
* Rounds 3 digits
* @param {number} n
* @returns {number}
*/
export function round3Digits(n) {
return Math_floor(n * 1000.0) / 1000.0;
}
/**
* Rounds 4 digits
* @param {number} n
* @returns {number}
*/
export function round4Digits(n) {
return Math_floor(n * 10000.0) / 10000.0;
}
/**
* Clamps a value between [min, max]
* @param {number} v
* @param {number=} minimum Default 0
* @param {number=} maximum Default 1
*/
export function clamp(v, minimum = 0, maximum = 1) {
return Math_max(minimum, Math_min(maximum, v));
}
/**
* Measures how long a function took
* @param {string} name
* @param {function():void} target
*/
export function measure(name, target) {
const now = performanceNow();
for (let i = 0; i < 25; ++i) {
target();
}
const dur = (performanceNow() - now) / 25.0;
console.warn("->", name, "took", dur.toFixed(2), "ms");
}
/**
* Helper method to create a new div
* @param {Element} parent
* @param {string=} id
* @param {Array<string>=} classes
* @param {string=} innerHTML
*/
export function makeDiv(parent, id = null, classes = [], innerHTML = "") {
const div = document.createElement("div");
if (id) {
div.id = id;
}
for (let i = 0; i < classes.length; ++i) {
div.classList.add(classes[i]);
}
div.innerHTML = innerHTML;
parent.appendChild(div);
return div;
}
/**
* Removes all children of the given element
* @param {Element} elem
*/
export function removeAllChildren(elem) {
var range = document.createRange();
range.selectNodeContents(elem);
range.deleteContents();
}
export function smartFadeNumber(current, newOne, minFade = 0.01, maxFade = 0.9) {
const tolerance = Math.min(current, newOne) * 0.5 + 10;
let fade = minFade;
if (Math.abs(current - newOne) < tolerance) {
fade = maxFade;
}
return current * fade + newOne * (1 - fade);
}
/**
* Fixes lockstep simulation by converting times like 34.0000000003 to 34.00.
* We use 3 digits of precision, this allows to store sufficient precision of 1 ms without
* the risk to simulation errors due to resync issues
* @param {number} value
*/
export function quantizeFloat(value) {
return Math.round(value * 1000.0) / 1000.0;
}
/**
* Safe check to check if a timer is expired. quantizes numbers
* @param {number} now Current time
* @param {number} lastTick Last tick of the timer
* @param {number} tickRate Interval of the timer
*/
export function checkTimerExpired(now, lastTick, tickRate) {
if (!G_IS_PROD) {
if (quantizeFloat(now) !== now) {
console.error("Got non-quantizied time:" + now + " vs " + quantizeFloat(now));
now = quantizeFloat(now);
}
if (quantizeFloat(lastTick) !== lastTick) {
// FIXME: REENABLE
// console.error("Got non-quantizied timer:" + lastTick + " vs " + quantizeFloat(lastTick));
lastTick = quantizeFloat(lastTick);
}
} else {
// just to be safe
now = quantizeFloat(now);
lastTick = quantizeFloat(lastTick);
}
/*
Ok, so heres the issue (Died a bit while debugging it):
In multiplayer lockstep simulation, client A will simulate everything at T, but client B
will simulate it at T + 3. So we are running into the following precision issue:
Lets say on client A the time is T = 30. Then on clientB the time is T = 33.
Now, our timer takes 0.1 seconds and ticked at 29.90 - What does happen now?
Client A computes the timer and checks T > lastTick + interval. He computes
30 >= 29.90 + 0.1 <=> 30 >= 30.0000 <=> True <=> Tick performed
However, this is what it looks on client B:
33 >= 32.90 + 0.1 <=> 33 >= 32.999999999999998 <=> False <=> No tick performed!
This means that Client B will only tick at the *next* frame, which means it from now is out
of sync by one tick, which means the game will resync further or later and be not able to recover,
since it will run into the same issue over and over.
*/
// The next tick, in our example it would be 30.0000 / 32.99999999998. In order to fix it, we quantize
// it, so its now 30.0000 / 33.0000
const nextTick = quantizeFloat(lastTick + tickRate);
// This check is safe, but its the only check where you may compare times. You always need to use
// this method!
return now >= nextTick;
}
/**
* Returns if the game supports this browser
*/
export function isSupportedBrowser() {
if (navigator.userAgent.toLowerCase().indexOf("firefox") >= 0) {
return true;
}
return isSupportedBrowserForMultiplayer();
}
// https://stackoverflow.com/questions/4565112/javascript-how-to-find-out-if-the-user-browser-is-chrome/13348618#13348618
export function isSupportedBrowserForMultiplayer() {
// please note,
// that IE11 now returns undefined again for window.chrome
// and new Opera 30 outputs true for window.chrome
// but needs to check if window.opr is not undefined
// and new IE Edge outputs to true now for window.chrome
// and if not iOS Chrome check
// so use the below updated condition
if (G_IS_MOBILE_APP || G_IS_STANDALONE) {
return true;
}
// @ts-ignore
var isChromium = window.chrome;
var winNav = window.navigator;
var vendorName = winNav.vendor;
// @ts-ignore
var isOpera = typeof window.opr !== "undefined";
var isIEedge = winNav.userAgent.indexOf("Edge") > -1;
var isIOSChrome = winNav.userAgent.match("CriOS");
if (isIOSChrome) {
// is Google Chrome on IOS
return false;
} else if (
isChromium !== null &&
typeof isChromium !== "undefined" &&
vendorName === "Google Inc." &&
isIEedge === false
) {
// is Google Chrome
return true;
} else {
// not Google Chrome
return false;
}
}
/**
* Helper function to create a json schema object
* @param {any} properties
*/
export function schemaObject(properties) {
return {
type: "object",
required: Object.keys(properties).slice(),
additionalProperties: false,
properties,
};
}
/**
* Quickly
* @param {number} x
* @param {number} y
* @param {number} deg
* @returns {Vector}
*/
export function fastRotateMultipleOf90(x, y, deg) {
switch (deg) {
case 0: {
return new Vector(x, y);
}
case 90: {
return new Vector(x, y);
}
}
}

665
src/js/core/vector.js Normal file
View File

@@ -0,0 +1,665 @@
import { globalConfig } from "./config";
import {
Math_abs,
Math_floor,
Math_PI,
Math_max,
Math_min,
Math_round,
Math_hypot,
Math_atan2,
Math_sin,
Math_cos,
} from "./builtins";
const tileSize = globalConfig.tileSize;
const halfTileSize = globalConfig.halfTileSize;
/**
* @enum {string}
*/
export const enumDirection = {
top: "top",
right: "right",
bottom: "bottom",
left: "left",
};
/**
* @enum {string}
*/
export const enumInvertedDirections = {
[enumDirection.top]: enumDirection.bottom,
[enumDirection.right]: enumDirection.left,
[enumDirection.bottom]: enumDirection.top,
[enumDirection.left]: enumDirection.right,
};
/**
* @enum {number}
*/
export const enumDirectionToAngle = {
[enumDirection.top]: 0,
[enumDirection.right]: 90,
[enumDirection.bottom]: 180,
[enumDirection.left]: 270,
};
/**
* @enum {enumDirection}
*/
export const enumAngleToDirection = {
0: enumDirection.top,
90: enumDirection.right,
180: enumDirection.bottom,
270: enumDirection.left,
};
export class Vector {
/**
*
* @param {number=} x
* @param {number=} y
*/
constructor(x, y) {
this.x = x || 0;
this.y = y || 0;
}
/**
* return a copy of the vector
* @returns {Vector}
*/
copy() {
return new Vector(this.x, this.y);
}
/**
* Adds a vector and return a new vector
* @param {Vector} other
* @returns {Vector}
*/
add(other) {
return new Vector(this.x + other.x, this.y + other.y);
}
/**
* Adds a vector
* @param {Vector} other
* @returns {Vector}
*/
addInplace(other) {
this.x += other.x;
this.y += other.y;
return this;
}
/**
* Substracts a vector and return a new vector
* @param {Vector} other
* @returns {Vector}
*/
sub(other) {
return new Vector(this.x - other.x, this.y - other.y);
}
/**
* Multiplies with a vector and return a new vector
* @param {Vector} other
* @returns {Vector}
*/
mul(other) {
return new Vector(this.x * other.x, this.y * other.y);
}
/**
* Adds two scalars and return a new vector
* @param {number} x
* @param {number} y
* @returns {Vector}
*/
addScalars(x, y) {
return new Vector(this.x + x, this.y + y);
}
/**
* Substracts a scalar and return a new vector
* @param {number} f
* @returns {Vector}
*/
subScalar(f) {
return new Vector(this.x - f, this.y - f);
}
/**
* Substracts two scalars and return a new vector
* @param {number} x
* @param {number} y
* @returns {Vector}
*/
subScalars(x, y) {
return new Vector(this.x - x, this.y - y);
}
/**
* Returns the euclidian length
* @returns {number}
*/
length() {
return Math_hypot(this.x, this.y);
}
/**
* Returns the square length
* @returns {number}
*/
lengthSquare() {
return this.x * this.x + this.y * this.y;
}
/**
* Divides both components by a scalar and return a new vector
* @param {number} f
* @returns {Vector}
*/
divideScalar(f) {
return new Vector(this.x / f, this.y / f);
}
/**
* Divides both components by the given scalars and return a new vector
* @param {number} a
* @param {number} b
* @returns {Vector}
*/
divideScalars(a, b) {
return new Vector(this.x / a, this.y / b);
}
/**
* Divides both components by a scalar
* @param {number} f
* @returns {Vector}
*/
divideScalarInplace(f) {
this.x /= f;
this.y /= f;
return this;
}
/**
* Multiplies both components with a scalar and return a new vector
* @param {number} f
* @returns {Vector}
*/
multiplyScalar(f) {
return new Vector(this.x * f, this.y * f);
}
/**
* Multiplies both components with two scalars and returns a new vector
* @param {number} a
* @param {number} b
* @returns {Vector}
*/
multiplyScalars(a, b) {
return new Vector(this.x * a, this.y * b);
}
/**
* For both components, compute the maximum of each component and the given scalar, and return a new vector.
* For example:
* - new Vector(-1, 5).maxScalar(0) -> Vector(0, 5)
* @param {number} f
* @returns {Vector}
*/
maxScalar(f) {
return new Vector(Math_max(f, this.x), Math_max(f, this.y));
}
/**
* Adds a scalar to both components and return a new vector
* @param {number} f
* @returns {Vector}
*/
addScalar(f) {
return new Vector(this.x + f, this.y + f);
}
/**
* Computes the component wise minimum and return a new vector
* @param {Vector} v
* @returns {Vector}
*/
min(v) {
return new Vector(Math_min(v.x, this.x), Math_min(v.y, this.y));
}
/**
* Computes the component wise maximum and return a new vector
* @param {Vector} v
* @returns {Vector}
*/
max(v) {
return new Vector(Math_max(v.x, this.x), Math_max(v.y, this.y));
}
/**
* Computes the component wise absolute
* @returns {Vector}
*/
abs() {
return new Vector(Math_abs(this.x), Math_abs(this.y));
}
/**
* Computes the scalar product
* @param {Vector} v
* @returns {number}
*/
dot(v) {
return this.x * v.x + this.y * v.y;
}
/**
* Computes the distance to a given vector
* @param {Vector} v
* @returns {number}
*/
distance(v) {
return Math_hypot(this.x - v.x, this.y - v.y);
}
/**
* Computes the square distance to a given vectort
* @param {Vector} v
* @returns {number}
*/
distanceSquare(v) {
const dx = this.x - v.x;
const dy = this.y - v.y;
return dx * dx + dy * dy;
}
/**
* Computes and returns the center between both points
* @param {Vector} v
* @returns {Vector}
*/
centerPoint(v) {
const cx = this.x + v.x;
const cy = this.y + v.y;
return new Vector(cx / 2, cy / 2);
}
/**
* Computes componentwise floor and return a new vector
* @returns {Vector}
*/
floor() {
return new Vector(Math_floor(this.x), Math_floor(this.y));
}
/**
* Computes componentwise round and return a new vector
* @returns {Vector}
*/
round() {
return new Vector(Math_round(this.x), Math_round(this.y));
}
/**
* Converts this vector from world to tile space and return a new vector
* @returns {Vector}
*/
toTileSpace() {
return new Vector(Math_floor(this.x / tileSize), Math_floor(this.y / tileSize));
}
/**
* Converts this vector from world to street space and return a new vector
* @returns {Vector}
*/
toStreetSpace() {
return new Vector(Math_floor(this.x / halfTileSize + 0.25), Math_floor(this.y / halfTileSize + 0.25));
}
/**
* Converts this vector to world space and return a new vector
* @returns {Vector}
*/
toWorldSpace() {
return new Vector(this.x * tileSize, this.y * tileSize);
}
/**
* Converts this vector to world space and return a new vector
* @returns {Vector}
*/
toWorldSpaceCenterOfTile() {
return new Vector(this.x * tileSize + halfTileSize, this.y * tileSize + halfTileSize);
}
/**
* Converts the top left tile position of this vector
* @returns {Vector}
*/
snapWorldToTile() {
return new Vector(Math_floor(this.x / tileSize) * tileSize, Math_floor(this.y / tileSize) * tileSize);
}
/**
* Normalizes the vector, dividing by the length(), and return a new vector
* @returns {Vector}
*/
normalize() {
const len = Math_max(1e-5, Math_hypot(this.x, this.y));
return new Vector(this.x / len, this.y / len);
}
/**
* Normalizes the vector, dividing by the length(), and return a new vector
* @returns {Vector}
*/
normalizeIfGreaterOne() {
const len = Math_max(1, Math_hypot(this.x, this.y));
return new Vector(this.x / len, this.y / len);
}
/**
* Returns the normalized vector to the other point
* @param {Vector} v
* @returns {Vector}
*/
normalizedDirection(v) {
const dx = v.x - this.x;
const dy = v.y - this.y;
const len = Math_max(1e-5, Math_hypot(dx, dy));
return new Vector(dx / len, dy / len);
}
/**
* Returns a perpendicular vector
* @returns {Vector}
*/
findPerpendicular() {
return new Vector(-this.y, this.x);
}
/**
* Returns the unnormalized direction to the other point
* @param {Vector} v
* @returns {Vector}
*/
direction(v) {
return new Vector(v.x - this.x, v.y - this.y);
}
/**
* Returns a string representation of the vector
* @returns {string}
*/
toString() {
return this.x + "," + this.y;
}
/**
* Compares both vectors for exact equality. Does not do an epsilon compare
* @param {Vector} v
* @returns {Boolean}
*/
equals(v) {
return this.x === v.x && this.y === v.y;
}
/**
* Rotates this vector
* @param {number} angle
* @returns {Vector} new vector
*/
rotated(angle) {
const sin = Math_sin(angle);
const cos = Math_cos(angle);
return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
}
/**
* Rotates this vector
* @param {number} angle
* @returns {Vector} this vector
*/
rotateInplaceFastMultipleOf90(angle) {
// const sin = Math_sin(angle);
// const cos = Math_cos(angle);
// let sin = 0, cos = 1;
assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle);
switch (angle) {
case 0:
case 360: {
return this;
}
case 90: {
// sin = 1;
// cos = 0;
const x = this.x;
this.x = -this.y;
this.y = x;
return this;
}
case 180: {
// sin = 0
// cos = -1
this.x = -this.x;
this.y = -this.y;
return this;
}
case 270: {
// sin = -1
// cos = 0
const x = this.x;
this.x = this.y;
this.y = -x;
return this;
}
default: {
assertAlways(false, "Invalid fast inplace rotation: " + angle);
return this;
}
}
// return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
}
/**
* Rotates this vector
* @param {number} angle
* @returns {Vector} new vector
*/
rotateFastMultipleOf90(angle) {
assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle);
switch (angle) {
case 360:
case 0: {
return new Vector(this.x, this.y);
}
case 90: {
return new Vector(-this.y, this.x);
}
case 180: {
return new Vector(-this.x, -this.y);
}
case 270: {
return new Vector(this.y, -this.x);
}
default: {
assertAlways(false, "Invalid fast inplace rotation: " + angle);
return new Vector();
}
}
}
/**
* Helper method to rotate a direction
* @param {enumDirection} direction
* @param {number} angle
* @returns {enumDirection}
*/
static transformDirectionFromMultipleOf90(direction, angle) {
if (angle === 0 || angle === 360) {
return direction;
}
assert(angle >= 0 && angle <= 360, "Invalid angle: " + angle);
switch (direction) {
case enumDirection.top: {
switch (angle) {
case 90:
return enumDirection.right;
case 180:
return enumDirection.bottom;
case 270:
return enumDirection.left;
default:
assertAlways(false, "Invalid angle: " + angle);
return;
}
}
case enumDirection.right: {
switch (angle) {
case 90:
return enumDirection.bottom;
case 180:
return enumDirection.left;
case 270:
return enumDirection.top;
default:
assertAlways(false, "Invalid angle: " + angle);
return;
}
}
case enumDirection.bottom: {
switch (angle) {
case 90:
return enumDirection.left;
case 180:
return enumDirection.top;
case 270:
return enumDirection.right;
default:
assertAlways(false, "Invalid angle: " + angle);
return;
}
}
case enumDirection.left: {
switch (angle) {
case 90:
return enumDirection.top;
case 180:
return enumDirection.right;
case 270:
return enumDirection.bottom;
default:
assertAlways(false, "Invalid angle: " + angle);
return;
}
}
default:
assertAlways(false, "Invalid angle: " + angle);
return;
}
}
/**
* Compares both vectors for epsilon equality
* @param {Vector} v
* @returns {Boolean}
*/
equalsEpsilon(v, epsilon = 1e-5) {
return Math_abs(this.x - v.x) < 1e-5 && Math_abs(this.y - v.y) < epsilon;
}
/**
* Returns the angle
* @returns {number} 0 .. 2 PI
*/
angle() {
return Math_atan2(this.y, this.x) + Math_PI / 2;
}
/**
* Serializes the vector to a string
* @returns {string}
*/
serializeTile() {
return String.fromCharCode(33 + this.x) + String.fromCharCode(33 + this.y);
}
/**
* Creates a simple representation of the vector
*/
serializeSimple() {
return { x: this.x, y: this.y };
}
/**
* @returns {number}
*/
serializeTileToInt() {
return this.x + this.y * 256;
}
/**
*
* @param {number} i
* @returns {Vector}
*/
static deserializeTileFromInt(i) {
const x = i % 256;
const y = Math_floor(i / 256);
return new Vector(x, y);
}
/**
* Deserializes a vector from a string
* @param {string} s
* @returns {Vector}
*/
static deserializeTile(s) {
return new Vector(s.charCodeAt(0) - 33, s.charCodeAt(1) - 33);
}
/**
* Deserializes a vector from a serialized json object
* @param {object} obj
* @returns {Vector}
*/
static fromSerializedObject(obj) {
if (obj) {
return new Vector(obj.x || 0, obj.y || 0);
}
}
}
/**
* Interpolates two vectors, for a = 0, returns v1 and for a = 1 return v2, otherwise interpolate
* @param {Vector} v1
* @param {Vector} v2
* @param {number} a
*/
export function mixVector(v1, v2, a) {
return new Vector(v1.x * (1 - a) + v2.x * a, v1.y * (1 - a) + v2.y * a);
}
/**
* Mapping from string direction to actual vector
* @enum {Vector}
*/
export const enumDirectionToVector = {
top: new Vector(0, -1),
right: new Vector(1, 0),
bottom: new Vector(0, 1),
left: new Vector(-1, 0),
};

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

Some files were not shown because too many files have changed in this diff Show More