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:
107
src/css/adinplay.scss
Normal file
107
src/css/adinplay.scss
Normal 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
13
src/css/animations.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
67
src/css/application_error.scss
Normal file
67
src/css/application_error.scss
Normal 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
662
src/css/common.scss
Normal 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
43
src/css/dynamic_ui.scss
Normal 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
31
src/css/game_state.scss
Normal 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
22
src/css/icons.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
8
src/css/ingame_hud/beta_overlay.scss
Normal file
8
src/css/ingame_hud/beta_overlay.scss
Normal 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;
|
||||
}
|
||||
8
src/css/ingame_hud/blur_overlay.scss
Normal file
8
src/css/ingame_hud/blur_overlay.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
body.ingameDialogOpen {
|
||||
#ingame_Canvas,
|
||||
#ingame_HUD_GameMenu,
|
||||
#ingame_HUD_KeybindingOverlay,
|
||||
#ingame_HUD_buildings_toolbar {
|
||||
filter: blur(5px);
|
||||
}
|
||||
}
|
||||
35
src/css/ingame_hud/building_placer.scss
Normal file
35
src/css/ingame_hud/building_placer.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
181
src/css/ingame_hud/buildings_toolbar.scss
Normal file
181
src/css/ingame_hud/buildings_toolbar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/css/ingame_hud/dialogs.scss
Normal file
63
src/css/ingame_hud/dialogs.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/css/ingame_hud/game_menu.scss
Normal file
59
src/css/ingame_hud/game_menu.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/css/ingame_hud/keybindings_overlay.scss
Normal file
78
src/css/ingame_hud/keybindings_overlay.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
180
src/css/ingame_hud/shop.scss
Normal file
180
src/css/ingame_hud/shop.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
src/css/ingame_hud/unlock_notification.scss
Normal file
134
src/css/ingame_hud/unlock_notification.scss
Normal 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
50
src/css/main.scss
Normal 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;
|
||||
}
|
||||
319
src/css/material_colors.scss
Normal file
319
src/css/material_colors.scss
Normal 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
379
src/css/mixins.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/css/states/ingame.scss
Normal file
16
src/css/states/ingame.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/css/states/main_menu.scss
Normal file
27
src/css/states/main_menu.scss
Normal 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
100
src/css/states/preload.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/css/textual_game_state.scss
Normal file
267
src/css/textual_game_state.scss
Normal 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
66
src/css/trigonometry.scss
Normal 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
195
src/css/variables.scss
Normal 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
43
src/html/index.html
Normal 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
362
src/js/application.js
Normal 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);
|
||||
}
|
||||
}
|
||||
71
src/js/core/animation_frame.js
Normal file
71
src/js/core/animation_frame.js
Normal 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
26
src/js/core/assert.js
Normal 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();
|
||||
143
src/js/core/async_compression.js
Normal file
143
src/js/core/async_compression.js
Normal 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();
|
||||
38
src/js/core/atlas_definitions.js
Normal file
38
src/js/core/atlas_definitions.js
Normal 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),
|
||||
// };
|
||||
216
src/js/core/background_resources_loader.js
Normal file
216
src/js/core/background_resources_loader.js
Normal 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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
146
src/js/core/buffer_maintainer.js
Normal file
146
src/js/core/buffer_maintainer.js
Normal 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
201
src/js/core/buffer_utils.js
Normal 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
34
src/js/core/builtins.js
Normal 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
10
src/js/core/cachebust.js
Normal 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;
|
||||
}
|
||||
431
src/js/core/click_detector.js
Normal file
431
src/js/core/click_detector.js
Normal 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
104
src/js/core/config.js
Normal 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
117
src/js/core/dpi_manager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/js/core/draw_parameters.js
Normal file
25
src/js/core/draw_parameters.js
Normal 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
321
src/js/core/draw_utils.js
Normal 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;
|
||||
}
|
||||
|
||||
// Cohen–Sutherland 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);
|
||||
}
|
||||
126
src/js/core/error_handler.js
Normal file
126
src/js/core/error_handler.js
Normal 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:
|
||||
<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;
|
||||
40
src/js/core/explained_result.js
Normal file
40
src/js/core/explained_result.js
Normal 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
81
src/js/core/factory.js
Normal 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
365
src/js/core/game_state.js
Normal 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();
|
||||
}
|
||||
}
|
||||
35
src/js/core/global_registries.js
Normal file
35
src/js/core/global_registries.js
Normal 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
17
src/js/core/globals.js
Normal 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;
|
||||
}
|
||||
235
src/js/core/input_distributor.js
Normal file
235
src/js/core/input_distributor.js
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
25
src/js/core/input_receiver.js
Normal file
25
src/js/core/input_receiver.js
Normal 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
243
src/js/core/loader.js
Normal 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
249
src/js/core/logging.js
Normal 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
493
src/js/core/lzstring.js
Normal 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
175
src/js/core/perlin_noise.js
Normal 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);
|
||||
}
|
||||
}
|
||||
258
src/js/core/perlin_noise_data.js
Normal file
258
src/js/core/perlin_noise_data.js
Normal 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
69
src/js/core/polyfills.js
Normal 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();
|
||||
10
src/js/core/query_parameters.js
Normal file
10
src/js/core/query_parameters.js
Normal 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;
|
||||
}
|
||||
300
src/js/core/read_write_proxy.js
Normal file
300
src/js/core/read_write_proxy.js
Normal 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
287
src/js/core/rectangle.js
Normal 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;
|
||||
}
|
||||
}
|
||||
72
src/js/core/request_channel.js
Normal file
72
src/js/core/request_channel.js
Normal 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
133
src/js/core/rng.js
Normal 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);
|
||||
}
|
||||
}
|
||||
62
src/js/core/sensitive_utils.encrypt.js
Normal file
62
src/js/core/sensitive_utils.encrypt.js
Normal 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
66
src/js/core/signal.js
Normal 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;
|
||||
}
|
||||
}
|
||||
78
src/js/core/singleton_factory.js
Normal file
78
src/js/core/singleton_factory.js
Normal 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
351
src/js/core/sprites.js
Normal 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);
|
||||
}
|
||||
}
|
||||
121
src/js/core/state_manager.js
Normal file
121
src/js/core/state_manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
39
src/js/core/tracked_state.js
Normal file
39
src/js/core/tracked_state.js
Normal 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
889
src/js/core/utils.js
Normal 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
665
src/js/core/vector.js
Normal 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),
|
||||
};
|
||||
80
src/js/game/automatic_save.js
Normal file
80
src/js/game/automatic_save.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { GameRoot } from "./root";
|
||||
import { globalConfig, IS_DEBUG } from "../core/config";
|
||||
import { Math_max } from "../core/builtins";
|
||||
|
||||
// How important it is that a savegame is created
|
||||
/**
|
||||
* @enum {number}
|
||||
*/
|
||||
export const enumSavePriority = {
|
||||
regular: 2,
|
||||
asap: 100,
|
||||
};
|
||||
|
||||
// Internals
|
||||
let MIN_INTERVAL_SECS = 15;
|
||||
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
// // Testing
|
||||
// MIN_INTERVAL_SECS = 1;
|
||||
// MAX_INTERVAL_SECS = 1;
|
||||
MIN_INTERVAL_SECS = 9999999;
|
||||
}
|
||||
|
||||
export class AutomaticSave {
|
||||
constructor(root) {
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
// Store the current maximum save importance
|
||||
this.saveImportance = enumSavePriority.regular;
|
||||
|
||||
this.lastSaveAttempt = -1000;
|
||||
}
|
||||
|
||||
setSaveImportance(importance) {
|
||||
this.saveImportance = Math_max(this.saveImportance, importance);
|
||||
}
|
||||
|
||||
doSave() {
|
||||
if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.root.gameState.doSave();
|
||||
this.saveImportance = enumSavePriority.regular;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.root.gameInitialized) {
|
||||
// Bad idea
|
||||
return;
|
||||
}
|
||||
// Check when the last save was, but make sure that if it fails, we don't spam
|
||||
const lastSaveTime = Math_max(this.lastSaveAttempt, this.root.savegame.getRealLastUpdate());
|
||||
|
||||
let secondsSinceLastSave = (Date.now() - lastSaveTime) / 1000.0;
|
||||
let shouldSave = false;
|
||||
|
||||
switch (this.saveImportance) {
|
||||
case enumSavePriority.asap:
|
||||
// High always should save
|
||||
shouldSave = true;
|
||||
break;
|
||||
|
||||
case enumSavePriority.regular:
|
||||
// Could determine if there is a good / bad point here
|
||||
shouldSave = secondsSinceLastSave > MIN_INTERVAL_SECS;
|
||||
break;
|
||||
|
||||
default:
|
||||
assert(false, "Unknown save prio: " + this.saveImportance);
|
||||
break;
|
||||
}
|
||||
if (shouldSave) {
|
||||
// log(this, "Saving automatically");
|
||||
this.lastSaveAttempt = Date.now();
|
||||
this.doSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/js/game/base_item.js
Normal file
33
src/js/game/base_item.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
|
||||
/**
|
||||
* Class for items on belts etc. Not an entity for performance reasons
|
||||
*/
|
||||
export class BaseItem extends BasicSerializableObject {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "base_item";
|
||||
}
|
||||
|
||||
/** @returns {object} */
|
||||
static getSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the item at the given position
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number=} size
|
||||
*/
|
||||
draw(x, y, parameters, size) {}
|
||||
|
||||
getBackgroundColorAsResource() {
|
||||
return "#eaebec";
|
||||
}
|
||||
}
|
||||
204
src/js/game/buildings/belt_base.js
Normal file
204
src/js/game/buildings/belt_base.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Loader } from "../../core/loader";
|
||||
import { enumAngleToDirection, enumDirection, Vector } from "../../core/vector";
|
||||
import { BeltComponent } from "../components/belt";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { ReplaceableMapEntityComponent } from "../components/replaceable_map_entity";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export const arrayBeltVariantToRotation = [enumDirection.top, enumDirection.left, enumDirection.right];
|
||||
|
||||
export class MetaBeltBaseBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("belt");
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#777";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new BeltComponent({
|
||||
direction: enumDirection.top, // updated later
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
direction: enumDirection.top, // updated later
|
||||
},
|
||||
],
|
||||
instantEject: true,
|
||||
})
|
||||
);
|
||||
// Make this entity replaceabel
|
||||
entity.addComponent(new ReplaceableMapEntityComponent());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
* @param {number} rotationVariant
|
||||
*/
|
||||
updateRotationVariant(entity, rotationVariant) {
|
||||
entity.components.Belt.direction = arrayBeltVariantToRotation[rotationVariant];
|
||||
entity.components.ItemEjector.slots[0].direction = arrayBeltVariantToRotation[rotationVariant];
|
||||
|
||||
entity.components.StaticMapEntity.spriteKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes optimal belt rotation variant
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} tile
|
||||
* @param {number} rotation
|
||||
* @return {{ rotation: number, rotationVariant: number }}
|
||||
*/
|
||||
computeOptimalDirectionAndRotationVariantAtTile(root, tile, rotation) {
|
||||
const topDirection = enumAngleToDirection[rotation];
|
||||
const rightDirection = enumAngleToDirection[(rotation + 90) % 360];
|
||||
const bottomDirection = enumAngleToDirection[(rotation + 180) % 360];
|
||||
const leftDirection = enumAngleToDirection[(rotation + 270) % 360];
|
||||
|
||||
const { ejectors, acceptors } = root.logic.getEjectorsAndAcceptorsAtTile(tile);
|
||||
|
||||
let hasBottomEjector = false;
|
||||
let hasLeftEjector = false;
|
||||
let hasRightEjector = false;
|
||||
|
||||
let hasTopAcceptor = false;
|
||||
let hasLeftAcceptor = false;
|
||||
let hasRightAcceptor = false;
|
||||
|
||||
// Check all ejectors
|
||||
for (let i = 0; i < ejectors.length; ++i) {
|
||||
const ejector = ejectors[i];
|
||||
|
||||
if (ejector.toDirection === topDirection) {
|
||||
hasBottomEjector = true;
|
||||
} else if (ejector.toDirection === leftDirection) {
|
||||
hasLeftEjector = true;
|
||||
} else if (ejector.toDirection === rightDirection) {
|
||||
hasRightEjector = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check all acceptors
|
||||
for (let i = 0; i < acceptors.length; ++i) {
|
||||
const acceptor = acceptors[i];
|
||||
if (acceptor.fromDirection === bottomDirection) {
|
||||
hasTopAcceptor = true;
|
||||
} else if (acceptor.fromDirection === rightDirection) {
|
||||
hasLeftAcceptor = true;
|
||||
} else if (acceptor.fromDirection === leftDirection) {
|
||||
hasRightAcceptor = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Soo .. if there is any ejector below us we always prioritize
|
||||
// this ejector
|
||||
if (!hasBottomEjector) {
|
||||
// When something ejects to us from the left and nothing from the right,
|
||||
// do a curve from the left to the top
|
||||
if (hasLeftEjector && !hasRightEjector) {
|
||||
return {
|
||||
rotation: (rotation + 270) % 360,
|
||||
rotationVariant: 2,
|
||||
};
|
||||
}
|
||||
|
||||
// When something ejects to us from the right and nothing from the left,
|
||||
// do a curve from the right to the top
|
||||
if (hasRightEjector && !hasLeftEjector) {
|
||||
return {
|
||||
rotation: (rotation + 90) % 360,
|
||||
rotationVariant: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// When there is a top acceptor, ignore sides
|
||||
// NOTICE: This makes the belt prefer side turns *way* too much!
|
||||
// if (!hasTopAcceptor) {
|
||||
// // When there is an acceptor to the right but no acceptor to the left,
|
||||
// // do a turn to the right
|
||||
// if (hasRightAcceptor && !hasLeftAcceptor) {
|
||||
// return {
|
||||
// rotation,
|
||||
// rotationVariant: 2,
|
||||
// };
|
||||
// }
|
||||
|
||||
// // When there is an acceptor to the left but no acceptor to the right,
|
||||
// // do a turn to the left
|
||||
// if (hasLeftAcceptor && !hasRightAcceptor) {
|
||||
// return {
|
||||
// rotation,
|
||||
// rotationVariant: 1,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
rotation,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Belt";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Transports items, hold and drag to place multiple, press 'R' to rotate.";
|
||||
}
|
||||
|
||||
getPreviewSprite(rotationVariant) {
|
||||
switch (arrayBeltVariantToRotation[rotationVariant]) {
|
||||
case enumDirection.top: {
|
||||
return Loader.getSprite("sprites/belt/forward_0.png");
|
||||
}
|
||||
case enumDirection.left: {
|
||||
return Loader.getSprite("sprites/belt/left_0.png");
|
||||
}
|
||||
case enumDirection.right: {
|
||||
return Loader.getSprite("sprites/belt/right_0.png");
|
||||
}
|
||||
default: {
|
||||
assertAlways(false, "Invalid belt rotation variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStayInPlacementMode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be overridden
|
||||
*/
|
||||
internalGetBeltDirection(rotationVariant) {
|
||||
return enumDirection.top;
|
||||
}
|
||||
}
|
||||
71
src/js/game/buildings/cutter.js
Normal file
71
src/js/game/buildings/cutter.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaCutterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("cutter");
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#7dcda2";
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Cut Half";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Cuts shapes from top to bottom and outputs both halfs. <strong>If you use only one part, be sure to destroy the other part or it will stall!</strong>";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.cutter,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
125
src/js/game/buildings/hub.js
Normal file
125
src/js/game/buildings/hub.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { ItemProcessorComponent, enumItemProcessorTypes } from "../components/item_processor";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { UnremovableComponent } from "../components/unremovable";
|
||||
import { HubComponent } from "../components/hub";
|
||||
|
||||
export class MetaHubBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("hub");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(4, 4);
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#eb5555";
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Hub";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Your central hub, deliver shapes to it to unlock new buildings.";
|
||||
}
|
||||
|
||||
isRotateable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(new HubComponent());
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.hub,
|
||||
})
|
||||
);
|
||||
entity.addComponent(new UnremovableComponent());
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.top, enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.top],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(2, 0),
|
||||
directions: [enumDirection.top],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 0),
|
||||
directions: [enumDirection.top, enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 3),
|
||||
directions: [enumDirection.bottom, enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 3),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(2, 3),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 3),
|
||||
directions: [enumDirection.bottom, enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 1),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 2),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 3),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 1),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 2),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 3),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/js/game/buildings/miner.js
Normal file
36
src/js/game/buildings/miner.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { MinerComponent } from "../components/miner";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
|
||||
export class MetaMinerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("miner");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Extract";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#b37dcd";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Place over a shape or color to extract it. Six extractors fill exactly one belt.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(new MinerComponent({}));
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/mixer.js
Normal file
73
src/js/game/buildings/mixer.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaMixerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("mixer");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Mix Colors";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Mixes two colors using additive blending.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cdbb7d";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_mixer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.mixer,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/painter.js
Normal file
73
src/js/game/buildings/painter.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaPainterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("painter");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Dye";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Colors the whole shape on the left input with the color from the right input.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cd9b7d";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_painter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.painter,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/js/game/buildings/rotater.js
Normal file
64
src/js/game/buildings/rotater.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaRotaterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("rotater");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Rotate";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Rotates shapes clockwise by 90 degrees.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#7dc6cd";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_rotater);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.rotater,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/js/game/buildings/splitter.js
Normal file
80
src/js/game/buildings/splitter.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaSplitterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("splitter");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Distribute";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#444";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Accepts up to two inputs and evenly distributes them on the outputs. Can also be used to merge two inputs into one output.";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_splitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.splitter,
|
||||
|
||||
beltUnderlays: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/stacker.js
Normal file
73
src/js/game/buildings/stacker.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaStackerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("stacker");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Combine";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#9fcd7d";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Combines both items. If they can not be merged, the right item is placed above the left item.";
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_stacker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.stacker,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/trash.js
Normal file
73
src/js/game/buildings/trash.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaTrashBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("trash");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Destroyer";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Accepts inputs from all sides and destroys them. Forever.";
|
||||
}
|
||||
|
||||
isRotateable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cd7d86";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.trash,
|
||||
})
|
||||
);
|
||||
|
||||
// Required, since the item processor needs this.
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [
|
||||
enumDirection.top,
|
||||
enumDirection.right,
|
||||
enumDirection.bottom,
|
||||
enumDirection.left,
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
158
src/js/game/buildings/underground_belt.js
Normal file
158
src/js/game/buildings/underground_belt.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Loader } from "../../core/loader";
|
||||
import { enumDirection, Vector, enumAngleToDirection, enumDirectionToVector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumUndergroundBeltMode, UndergroundBeltComponent } from "../components/underground_belt";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
/** @enum {string} */
|
||||
export const arrayUndergroundRotationVariantToMode = [
|
||||
enumUndergroundBeltMode.sender,
|
||||
enumUndergroundBeltMode.receiver,
|
||||
];
|
||||
|
||||
export class MetaUndergroundBeltBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("underground_belt");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Tunnel";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#555";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Allows to tunnel resources under buildings and belts.";
|
||||
}
|
||||
|
||||
getFlipOrientationAfterPlacement() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getStayInPlacementMode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getPreviewSprite(rotationVariant) {
|
||||
switch (arrayUndergroundRotationVariantToMode[rotationVariant]) {
|
||||
case enumUndergroundBeltMode.sender:
|
||||
return Loader.getSprite("sprites/buildings/underground_belt_entry.png");
|
||||
case enumUndergroundBeltMode.receiver:
|
||||
return Loader.getSprite("sprites/buildings/underground_belt_exit.png");
|
||||
default:
|
||||
assertAlways(false, "Invalid rotation variant");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_tunnel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
// Required, since the item processor needs this.
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(new UndergroundBeltComponent({}));
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} tile
|
||||
* @param {number} rotation
|
||||
* @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array<Entity> }}
|
||||
*/
|
||||
computeOptimalDirectionAndRotationVariantAtTile(root, tile, rotation) {
|
||||
const searchDirection = enumAngleToDirection[rotation];
|
||||
const searchVector = enumDirectionToVector[searchDirection];
|
||||
|
||||
const targetRotation = (rotation + 180) % 360;
|
||||
|
||||
for (let searchOffset = 1; searchOffset <= globalConfig.undergroundBeltMaxTiles; ++searchOffset) {
|
||||
tile = tile.addScalars(searchVector.x, searchVector.y);
|
||||
|
||||
const contents = root.map.getTileContent(tile);
|
||||
if (contents) {
|
||||
const undergroundComp = contents.components.UndergroundBelt;
|
||||
if (undergroundComp) {
|
||||
const staticComp = contents.components.StaticMapEntity;
|
||||
if (staticComp.rotationDegrees === targetRotation) {
|
||||
if (undergroundComp.mode !== enumUndergroundBeltMode.sender) {
|
||||
// If we encounter an underground receiver on our way which is also faced in our direction, we don't accept that
|
||||
break;
|
||||
}
|
||||
// console.log("GOT IT! rotation is", rotation, "and target is", staticComp.rotationDegrees);
|
||||
|
||||
return {
|
||||
rotation: targetRotation,
|
||||
rotationVariant: 1,
|
||||
connectedEntities: [contents],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rotation,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Entity} entity
|
||||
* @param {number} rotationVariant
|
||||
*/
|
||||
updateRotationVariant(entity, rotationVariant) {
|
||||
entity.components.StaticMapEntity.spriteKey = this.getPreviewSprite(rotationVariant).spriteName;
|
||||
|
||||
switch (arrayUndergroundRotationVariantToMode[rotationVariant]) {
|
||||
case enumUndergroundBeltMode.sender: {
|
||||
entity.components.UndergroundBelt.mode = enumUndergroundBeltMode.sender;
|
||||
entity.components.ItemEjector.setSlots([]);
|
||||
entity.components.ItemAcceptor.setSlots([
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
case enumUndergroundBeltMode.receiver: {
|
||||
entity.components.UndergroundBelt.mode = enumUndergroundBeltMode.receiver;
|
||||
entity.components.ItemAcceptor.setSlots([]);
|
||||
entity.components.ItemEjector.setSlots([
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
direction: enumDirection.top,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
assertAlways(false, "Invalid rotation variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
870
src/js/game/camera.js
Normal file
870
src/js/game/camera.js
Normal file
@@ -0,0 +1,870 @@
|
||||
import {
|
||||
Math_abs,
|
||||
Math_ceil,
|
||||
Math_floor,
|
||||
Math_min,
|
||||
Math_random,
|
||||
performanceNow,
|
||||
Math_max,
|
||||
} from "../core/builtins";
|
||||
import { Rectangle } from "../core/rectangle";
|
||||
import { Signal, STOP_PROPAGATION } from "../core/signal";
|
||||
import { clamp, lerp } from "../core/utils";
|
||||
import { mixVector, Vector } from "../core/vector";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { GameRoot } from "./root";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { clickDetectorGlobals } from "../core/click_detector";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("camera");
|
||||
|
||||
export const USER_INTERACT_MOVE = "move";
|
||||
export const USER_INTERACT_ZOOM = "zoom";
|
||||
export const USER_INTERACT_TOUCHEND = "touchend";
|
||||
|
||||
const velocitySmoothing = 0.5;
|
||||
const velocityFade = 0.98;
|
||||
const velocityStrength = 0.4;
|
||||
const velocityMax = 20;
|
||||
|
||||
export class Camera extends BasicSerializableObject {
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
// Zoom level, 2 means double size
|
||||
|
||||
// Find optimal initial zoom
|
||||
|
||||
this.zoomLevel = this.findInitialZoom();
|
||||
this.clampZoomLevel();
|
||||
|
||||
/** @type {Vector} */
|
||||
this.center = new Vector(0, 0);
|
||||
|
||||
// Input handling
|
||||
this.currentlyMoving = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.cameraUpdateTimeBucket = 0.0;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
this.currentlyPinching = false;
|
||||
this.lastPinchPositions = null;
|
||||
|
||||
this.keyboardForce = new Vector();
|
||||
|
||||
// Signal which gets emitted once the user changed something
|
||||
this.userInteraction = new Signal();
|
||||
|
||||
/** @type {Vector} */
|
||||
this.currentShake = new Vector(0, 0);
|
||||
|
||||
/** @type {Vector} */
|
||||
this.currentPan = new Vector(0, 0);
|
||||
|
||||
// Set desired pan (camera movement)
|
||||
/** @type {Vector} */
|
||||
this.desiredPan = new Vector(0, 0);
|
||||
|
||||
// Set desired camera center
|
||||
/** @type {Vector} */
|
||||
this.desiredCenter = null;
|
||||
|
||||
// Set desired camera zoom
|
||||
/** @type {number} */
|
||||
this.desiredZoom = null;
|
||||
|
||||
/** @type {Vector} */
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
|
||||
// Handlers
|
||||
this.downPreHandler = new Signal(/* pos */);
|
||||
this.movePreHandler = new Signal(/* pos */);
|
||||
this.pinchPreHandler = new Signal(/* pos */);
|
||||
this.upPostHandler = new Signal(/* pos */);
|
||||
|
||||
this.internalInitEvents();
|
||||
this.clampZoomLevel();
|
||||
this.bindKeys();
|
||||
}
|
||||
|
||||
// Serialization
|
||||
static getId() {
|
||||
return "Camera";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
zoomLevel: types.float,
|
||||
center: types.vector,
|
||||
};
|
||||
}
|
||||
|
||||
deserialize(data) {
|
||||
const errorCode = super.deserialize(data);
|
||||
if (errorCode) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
// Safety
|
||||
this.clampZoomLevel();
|
||||
}
|
||||
|
||||
// Simple geters & setters
|
||||
|
||||
addScreenShake(amount) {
|
||||
const currentShakeAmount = this.currentShake.length();
|
||||
const scale = 1 / (1 + 3 * currentShakeAmount);
|
||||
this.currentShake.x = this.currentShake.x + 2 * (Math_random() - 0.5) * scale * amount;
|
||||
this.currentShake.y = this.currentShake.y + 2 * (Math_random() - 0.5) * scale * amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a point in world space to focus on
|
||||
* @param {Vector} center
|
||||
*/
|
||||
setDesiredCenter(center) {
|
||||
this.desiredCenter = center.copy();
|
||||
this.currentlyMoving = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this camera is currently moving by a non-user interaction
|
||||
*/
|
||||
isCurrentlyMovingToDesiredCenter() {
|
||||
return this.desiredCenter !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the camera pan, every frame the camera will move by this amount
|
||||
* @param {Vector} pan
|
||||
*/
|
||||
setPan(pan) {
|
||||
this.desiredPan = pan.copy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a good initial zoom level
|
||||
*/
|
||||
findInitialZoom() {
|
||||
return 3;
|
||||
const desiredWorldSpaceWidth = 20 * globalConfig.tileSize;
|
||||
const zoomLevelX = this.root.gameWidth / desiredWorldSpaceWidth;
|
||||
const zoomLevelY = this.root.gameHeight / desiredWorldSpaceWidth;
|
||||
|
||||
const finalLevel = Math_min(zoomLevelX, zoomLevelY);
|
||||
assert(
|
||||
Number.isFinite(finalLevel) && finalLevel > 0,
|
||||
"Invalid zoom level computed for initial zoom: " + finalLevel
|
||||
);
|
||||
return finalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all animations
|
||||
*/
|
||||
clearAnimations() {
|
||||
this.touchPostMoveVelocity.x = 0;
|
||||
this.touchPostMoveVelocity.y = 0;
|
||||
this.desiredCenter = null;
|
||||
this.desiredPan.x = 0;
|
||||
this.desiredPan.y = 0;
|
||||
this.currentPan.x = 0;
|
||||
this.currentPan.y = 0;
|
||||
this.currentlyPinching = false;
|
||||
this.currentlyMoving = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the user is currently interacting with the camera
|
||||
* @returns {boolean} true if the user interacts
|
||||
*/
|
||||
isCurrentlyInteracting() {
|
||||
if (this.currentlyPinching) {
|
||||
return true;
|
||||
}
|
||||
if (this.currentlyMoving) {
|
||||
// Only interacting if moved at least once
|
||||
return this.didMoveSinceTouchStart;
|
||||
}
|
||||
if (this.touchPostMoveVelocity.lengthSquare() > 1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if in the next frame the viewport will change
|
||||
* @returns {boolean} true if it willchange
|
||||
*/
|
||||
viewportWillChange() {
|
||||
return this.desiredCenter !== null || this.desiredZoom !== null || this.isCurrentlyInteracting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all interactions, that is user interaction and non user interaction
|
||||
*/
|
||||
cancelAllInteractions() {
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
this.desiredCenter = null;
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = false;
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective viewport width
|
||||
*/
|
||||
getViewportWidth() {
|
||||
return this.root.gameWidth / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective viewport height
|
||||
*/
|
||||
getViewportHeight() {
|
||||
return this.root.gameHeight / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport left
|
||||
*/
|
||||
getViewportLeft() {
|
||||
return this.center.x - this.getViewportWidth() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport right
|
||||
*/
|
||||
getViewportRight() {
|
||||
return this.center.x + this.getViewportWidth() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport top
|
||||
*/
|
||||
getViewportTop() {
|
||||
return this.center.y - this.getViewportHeight() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport bottom
|
||||
*/
|
||||
getViewportBottom() {
|
||||
return this.center.y + this.getViewportHeight() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the visible world space rect
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
getVisibleRect() {
|
||||
return Rectangle.fromTRBL(
|
||||
Math_floor(this.getViewportTop()),
|
||||
Math_ceil(this.getViewportRight()),
|
||||
Math_ceil(this.getViewportBottom()),
|
||||
Math_floor(this.getViewportLeft())
|
||||
);
|
||||
}
|
||||
|
||||
getIsMapOverlayActive() {
|
||||
return this.zoomLevel < globalConfig.mapChunkOverviewMinZoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches all event listeners
|
||||
*/
|
||||
internalInitEvents() {
|
||||
this.eventListenerTouchStart = this.onTouchStart.bind(this);
|
||||
this.eventListenerTouchEnd = this.onTouchEnd.bind(this);
|
||||
this.eventListenerTouchMove = this.onTouchMove.bind(this);
|
||||
this.eventListenerMousewheel = this.onMouseWheel.bind(this);
|
||||
this.eventListenerMouseDown = this.onMouseDown.bind(this);
|
||||
this.eventListenerMouseMove = this.onMouseMove.bind(this);
|
||||
this.eventListenerMouseUp = this.onMouseUp.bind(this);
|
||||
|
||||
this.root.canvas.addEventListener("touchstart", this.eventListenerTouchStart);
|
||||
this.root.canvas.addEventListener("touchend", this.eventListenerTouchEnd);
|
||||
this.root.canvas.addEventListener("touchcancel", this.eventListenerTouchEnd);
|
||||
this.root.canvas.addEventListener("touchmove", this.eventListenerTouchMove);
|
||||
|
||||
this.root.canvas.addEventListener("wheel", this.eventListenerMousewheel);
|
||||
this.root.canvas.addEventListener("mousedown", this.eventListenerMouseDown);
|
||||
this.root.canvas.addEventListener("mousemove", this.eventListenerMouseMove);
|
||||
this.root.canvas.addEventListener("mouseup", this.eventListenerMouseUp);
|
||||
this.root.canvas.addEventListener("mouseout", this.eventListenerMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all event listeners
|
||||
*/
|
||||
cleanup() {
|
||||
this.root.canvas.removeEventListener("touchstart", this.eventListenerTouchStart);
|
||||
this.root.canvas.removeEventListener("touchend", this.eventListenerTouchEnd);
|
||||
this.root.canvas.removeEventListener("touchcancel", this.eventListenerTouchEnd);
|
||||
this.root.canvas.removeEventListener("touchmove", this.eventListenerTouchMove);
|
||||
|
||||
this.root.canvas.removeEventListener("wheel", this.eventListenerMousewheel);
|
||||
this.root.canvas.removeEventListener("mousedown", this.eventListenerMouseDown);
|
||||
this.root.canvas.removeEventListener("mousemove", this.eventListenerMouseMove);
|
||||
this.root.canvas.removeEventListener("mouseup", this.eventListenerMouseUp);
|
||||
this.root.canvas.removeEventListener("mouseout", this.eventListenerMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the arrow keys
|
||||
*/
|
||||
bindKeys() {
|
||||
const mapper = this.root.gameState.keyActionMapper;
|
||||
mapper.getBinding("map_move_up").add(() => (this.keyboardForce.y = -1));
|
||||
mapper.getBinding("map_move_down").add(() => (this.keyboardForce.y = 1));
|
||||
mapper.getBinding("map_move_right").add(() => (this.keyboardForce.x = 1));
|
||||
mapper.getBinding("map_move_left").add(() => (this.keyboardForce.x = -1));
|
||||
|
||||
mapper.getBinding("center_map").add(() => (this.desiredCenter = new Vector(0, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from screen to world space
|
||||
* @param {Vector} screen
|
||||
* @returns {Vector} world space
|
||||
*/
|
||||
screenToWorld(screen) {
|
||||
const centerSpace = screen.subScalars(this.root.gameWidth / 2, this.root.gameHeight / 2);
|
||||
return centerSpace.divideScalar(this.zoomLevel).add(this.center);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from world to screen space
|
||||
* @param {Vector} world
|
||||
* @returns {Vector} screen space
|
||||
*/
|
||||
worldToScreen(world) {
|
||||
const screenSpace = world.sub(this.center).multiplyScalar(this.zoomLevel);
|
||||
return screenSpace.addScalars(this.root.gameWidth / 2, this.root.gameHeight / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if a point is on screen
|
||||
* @param {Vector} point
|
||||
* @returns {boolean} true if its on screen
|
||||
*/
|
||||
isWorldPointOnScreen(point) {
|
||||
const rect = this.getVisibleRect();
|
||||
return rect.containsPoint(point.x, point.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can further zoom in
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canZoomIn() {
|
||||
const maxLevel = this.root.app.platformWrapper.getMaximumZoom();
|
||||
return this.zoomLevel <= maxLevel - 0.01;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can further zoom out
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canZoomOut() {
|
||||
const minLevel = this.root.app.platformWrapper.getMinimumZoom();
|
||||
return this.zoomLevel >= minLevel + 0.01;
|
||||
}
|
||||
|
||||
// EVENTS
|
||||
|
||||
/**
|
||||
* Checks if the mouse event is too close after a touch event and thus
|
||||
* should get ignored
|
||||
*/
|
||||
checkPreventDoubleMouse() {
|
||||
if (performanceNow() - clickDetectorGlobals.lastTouchTime < 1000.0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousedown handler
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
onMouseDown(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
if (event.which === 1) {
|
||||
this.combinedSingleTouchStartHandler(event.clientX, event.clientY);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousemove handler
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
onMouseMove(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.which === 1) {
|
||||
this.combinedSingleTouchMoveHandler(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
// Clamp everything afterwards
|
||||
this.clampZoomLevel();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouseup handler
|
||||
* @param {MouseEvent=} event
|
||||
*/
|
||||
onMouseUp(event) {
|
||||
if (event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.combinedSingleTouchStopHandler(event.clientX, event.clientY);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousewheel event
|
||||
* @param {WheelEvent} event
|
||||
*/
|
||||
onMouseWheel(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
const delta = Math.sign(event.deltaY) * -0.15;
|
||||
assert(Number.isFinite(delta), "Got invalid delta in mouse wheel event: " + event.deltaY);
|
||||
assert(Number.isFinite(this.zoomLevel), "Got invalid zoom level *before* wheel: " + this.zoomLevel);
|
||||
this.zoomLevel *= 1 + delta;
|
||||
assert(Number.isFinite(this.zoomLevel), "Got invalid zoom level *after* wheel: " + this.zoomLevel);
|
||||
|
||||
this.clampZoomLevel();
|
||||
this.desiredZoom = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch start handler
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
onTouchStart(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
this.combinedSingleTouchStartHandler(touch.clientX, touch.clientY);
|
||||
} else if (event.touches.length === 2) {
|
||||
if (this.pinchPreHandler.dispatch() === STOP_PROPAGATION) {
|
||||
// Something prevented pinching
|
||||
return false;
|
||||
}
|
||||
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = true;
|
||||
this.lastPinchPositions = [
|
||||
new Vector(touch1.clientX, touch1.clientY),
|
||||
new Vector(touch2.clientX, touch2.clientY),
|
||||
];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch move handler
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
onTouchMove(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
this.combinedSingleTouchMoveHandler(touch.clientX, touch.clientY);
|
||||
} else if (event.touches.length === 2) {
|
||||
if (this.currentlyPinching) {
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
|
||||
const newPinchPositions = [
|
||||
new Vector(touch1.clientX, touch1.clientY),
|
||||
new Vector(touch2.clientX, touch2.clientY),
|
||||
];
|
||||
|
||||
// Get distance of taps last time and now
|
||||
const lastDistance = this.lastPinchPositions[0].distance(this.lastPinchPositions[1]);
|
||||
const thisDistance = newPinchPositions[0].distance(newPinchPositions[1]);
|
||||
|
||||
// IMPORTANT to do math max here to avoid NaN and causing an invalid zoom level
|
||||
const difference = thisDistance / Math_max(0.001, lastDistance);
|
||||
|
||||
// Find old center of zoom
|
||||
let oldCenter = this.lastPinchPositions[0].centerPoint(this.lastPinchPositions[1]);
|
||||
|
||||
// Find new center of zoom
|
||||
let center = newPinchPositions[0].centerPoint(newPinchPositions[1]);
|
||||
|
||||
// Compute movement
|
||||
let movement = oldCenter.sub(center);
|
||||
this.center.x += movement.x / this.zoomLevel;
|
||||
this.center.y += movement.y / this.zoomLevel;
|
||||
|
||||
// Compute zoom
|
||||
center = center.sub(new Vector(this.root.gameWidth / 2, this.root.gameHeight / 2));
|
||||
|
||||
// Apply zoom
|
||||
assert(
|
||||
Number.isFinite(difference),
|
||||
"Invalid pinch difference: " +
|
||||
difference +
|
||||
"(last=" +
|
||||
lastDistance +
|
||||
", new = " +
|
||||
thisDistance +
|
||||
")"
|
||||
);
|
||||
this.zoomLevel *= difference;
|
||||
|
||||
// Stick to pivot point
|
||||
const correcture = center.multiplyScalar(difference - 1).divideScalar(this.zoomLevel);
|
||||
|
||||
this.center = this.center.add(correcture);
|
||||
this.lastPinchPositions = newPinchPositions;
|
||||
this.userInteraction.dispatch(USER_INTERACT_MOVE);
|
||||
|
||||
// Since we zoomed, abort any programmed zooming
|
||||
if (this.desiredZoom) {
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp everything afterwards
|
||||
this.clampZoomLevel();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch end and cancel handler
|
||||
* @param {TouchEvent=} event
|
||||
*/
|
||||
onTouchEnd(event) {
|
||||
if (event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
if (event.changedTouches.length === 0) {
|
||||
logger.warn("Touch end without changed touches");
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
this.combinedSingleTouchStopHandler(touch.clientX, touch.clientY);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch start handler
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
combinedSingleTouchStartHandler(x, y) {
|
||||
const pos = new Vector(x, y);
|
||||
if (this.downPreHandler.dispatch(pos) === STOP_PROPAGATION) {
|
||||
// Somebody else captured it
|
||||
return;
|
||||
}
|
||||
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
this.currentlyMoving = true;
|
||||
this.lastMovingPosition = pos;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch move handler
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
combinedSingleTouchMoveHandler(x, y) {
|
||||
const pos = new Vector(x, y);
|
||||
if (this.movePreHandler.dispatch(pos) === STOP_PROPAGATION) {
|
||||
// Somebody else captured it
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.currentlyMoving) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let delta = this.lastMovingPosition.sub(pos).divideScalar(this.zoomLevel);
|
||||
if (G_IS_DEV && globalConfig.debug.testCulling) {
|
||||
// When testing culling, we see everything from the same distance
|
||||
delta = delta.multiplyScalar(this.zoomLevel * -2);
|
||||
}
|
||||
|
||||
this.didMoveSinceTouchStart = this.didMoveSinceTouchStart || delta.length() > 0;
|
||||
this.center = this.center.add(delta);
|
||||
|
||||
this.touchPostMoveVelocity = this.touchPostMoveVelocity
|
||||
.multiplyScalar(velocitySmoothing)
|
||||
.add(delta.multiplyScalar(1 - velocitySmoothing));
|
||||
|
||||
this.lastMovingPosition = pos;
|
||||
this.userInteraction.dispatch(USER_INTERACT_MOVE);
|
||||
|
||||
// Since we moved, abort any programmed moving
|
||||
if (this.desiredCenter) {
|
||||
this.desiredCenter = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch stop handler
|
||||
*/
|
||||
combinedSingleTouchStopHandler(x, y) {
|
||||
if (this.currentlyMoving || this.currentlyPinching) {
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.lastPinchPositions = null;
|
||||
this.userInteraction.dispatch(USER_INTERACT_TOUCHEND);
|
||||
this.didMoveSinceTouchStart = false;
|
||||
}
|
||||
this.upPostHandler.dispatch(new Vector(x, y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps the camera zoom level within the allowed range
|
||||
*/
|
||||
clampZoomLevel() {
|
||||
if (G_IS_DEV && globalConfig.debug.disableZoomLimits) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = this.root.app.platformWrapper;
|
||||
|
||||
assert(Number.isFinite(this.zoomLevel), "Invalid zoom level *before* clamp: " + this.zoomLevel);
|
||||
this.zoomLevel = clamp(this.zoomLevel, wrapper.getMinimumZoom(), wrapper.getMaximumZoom());
|
||||
assert(Number.isFinite(this.zoomLevel), "Invalid zoom level *after* clamp: " + this.zoomLevel);
|
||||
|
||||
if (this.desiredZoom) {
|
||||
this.desiredZoom = clamp(this.desiredZoom, wrapper.getMinimumZoom(), wrapper.getMaximumZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the camera
|
||||
* @param {number} dt Delta time in milliseconds
|
||||
*/
|
||||
update(dt) {
|
||||
dt = Math_min(dt, 33);
|
||||
this.cameraUpdateTimeBucket += dt;
|
||||
|
||||
// Simulate movement of N FPS
|
||||
const updatesPerFrame = 4;
|
||||
const physicsStepSizeMs = 1000.0 / (60.0 * updatesPerFrame);
|
||||
|
||||
let now = this.root.time.systemNow() - 3 * physicsStepSizeMs;
|
||||
|
||||
while (this.cameraUpdateTimeBucket > physicsStepSizeMs) {
|
||||
now += physicsStepSizeMs;
|
||||
this.cameraUpdateTimeBucket -= physicsStepSizeMs;
|
||||
|
||||
this.internalUpdatePanning(now, physicsStepSizeMs);
|
||||
this.internalUpdateZooming(now, physicsStepSizeMs);
|
||||
this.internalUpdateCentering(now, physicsStepSizeMs);
|
||||
this.internalUpdateShake(now, physicsStepSizeMs);
|
||||
this.internalUpdateKeyboardForce(now, physicsStepSizeMs);
|
||||
}
|
||||
this.clampZoomLevel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a context to transform it
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
transform(context) {
|
||||
if (G_IS_DEV && globalConfig.debug.testCulling) {
|
||||
context.transform(1, 0, 0, 1, 100, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clampZoomLevel();
|
||||
const zoom = this.zoomLevel;
|
||||
|
||||
context.transform(
|
||||
// Scale, skew, rotate
|
||||
zoom,
|
||||
0,
|
||||
0,
|
||||
zoom,
|
||||
|
||||
// Translate
|
||||
-zoom * this.getViewportLeft(),
|
||||
-zoom * this.getViewportTop()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal shake handler
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateShake(now, dt) {
|
||||
this.currentShake = this.currentShake.multiplyScalar(0.92);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pan handler
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdatePanning(now, dt) {
|
||||
const baseStrength = velocityStrength * this.root.app.platformWrapper.getTouchPanStrength();
|
||||
|
||||
this.touchPostMoveVelocity = this.touchPostMoveVelocity.multiplyScalar(velocityFade);
|
||||
|
||||
// Check influence of past points
|
||||
if (!this.currentlyMoving && !this.currentlyPinching) {
|
||||
const len = this.touchPostMoveVelocity.length();
|
||||
if (len >= velocityMax) {
|
||||
this.touchPostMoveVelocity.x = (this.touchPostMoveVelocity.x * velocityMax) / len;
|
||||
this.touchPostMoveVelocity.y = (this.touchPostMoveVelocity.y * velocityMax) / len;
|
||||
}
|
||||
|
||||
this.center = this.center.add(this.touchPostMoveVelocity.multiplyScalar(baseStrength));
|
||||
|
||||
// Panning
|
||||
this.currentPan = mixVector(this.currentPan, this.desiredPan, 0.06);
|
||||
this.center = this.center.add(this.currentPan.multiplyScalar((0.5 * dt) / this.zoomLevel));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the non user interaction zooming
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateZooming(now, dt) {
|
||||
if (!this.currentlyPinching && this.desiredZoom !== null) {
|
||||
const diff = this.zoomLevel - this.desiredZoom;
|
||||
if (Math_abs(diff) > 0.05) {
|
||||
let fade = 0.94;
|
||||
if (diff > 0) {
|
||||
// Zoom out faster than in
|
||||
fade = 0.9;
|
||||
}
|
||||
|
||||
assert(Number.isFinite(this.desiredZoom), "Desired zoom is NaN: " + this.desiredZoom);
|
||||
assert(Number.isFinite(fade), "Zoom fade is NaN: " + fade);
|
||||
this.zoomLevel = this.zoomLevel * fade + this.desiredZoom * (1 - fade);
|
||||
assert(Number.isFinite(this.zoomLevel), "Zoom level is NaN after fade: " + this.zoomLevel);
|
||||
} else {
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the non user interaction centering
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateCentering(now, dt) {
|
||||
if (!this.currentlyMoving && this.desiredCenter !== null) {
|
||||
const diff = this.center.direction(this.desiredCenter);
|
||||
const length = diff.length();
|
||||
const tolerance = 1 / this.zoomLevel;
|
||||
if (length > tolerance) {
|
||||
const movement = diff.multiplyScalar(Math_min(1, dt * 0.008));
|
||||
this.center.x += movement.x;
|
||||
this.center.y += movement.y;
|
||||
} else {
|
||||
this.desiredCenter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the keyboard forces
|
||||
* @param {number} now
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateKeyboardForce(now, dt) {
|
||||
if (!this.currentlyMoving && this.desiredCenter == null) {
|
||||
const limitingDimension = Math_min(this.root.gameWidth, this.root.gameHeight);
|
||||
|
||||
const moveAmount = ((limitingDimension / 2048) * dt) / this.zoomLevel;
|
||||
|
||||
let forceX = 0;
|
||||
let forceY = 0;
|
||||
|
||||
const actionMapper = this.root.gameState.keyActionMapper;
|
||||
if (actionMapper.getBinding("map_move_up").currentlyDown) {
|
||||
forceY -= 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_down").currentlyDown) {
|
||||
forceY += 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_left").currentlyDown) {
|
||||
forceX -= 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_right").currentlyDown) {
|
||||
forceX += 1;
|
||||
}
|
||||
|
||||
this.center.x += moveAmount * forceX;
|
||||
this.center.y += moveAmount * forceY;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/js/game/canvas_click_interceptor.js
Normal file
70
src/js/game/canvas_click_interceptor.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { STOP_PROPAGATION } from "../core/signal";
|
||||
import { GameRoot } from "./root";
|
||||
import { ClickDetector } from "../core/click_detector";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("canvas_click_interceptor");
|
||||
|
||||
export class CanvasClickInterceptor {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
this.root.signals.postLoadHook.add(this.initialize, this);
|
||||
this.root.signals.aboutToDestruct.add(this.cleanup, this);
|
||||
|
||||
/** @type {Array<object>} */
|
||||
this.interceptors = [];
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.clickDetector = new ClickDetector(this.root.canvas, {
|
||||
applyCssClass: null,
|
||||
captureTouchmove: false,
|
||||
targetOnly: true,
|
||||
preventDefault: true,
|
||||
maxDistance: 13,
|
||||
clickSound: null,
|
||||
});
|
||||
this.clickDetector.click.add(this.onCanvasClick, this);
|
||||
this.clickDetector.rightClick.add(this.onCanvasRightClick, this);
|
||||
|
||||
if (this.root.hud.parts.buildingPlacer) {
|
||||
this.interceptors.push(this.root.hud.parts.buildingPlacer);
|
||||
}
|
||||
|
||||
logger.log("Registered", this.interceptors.length, "interceptors");
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.clickDetector) {
|
||||
this.clickDetector.cleanup();
|
||||
}
|
||||
this.interceptors = [];
|
||||
}
|
||||
|
||||
onCanvasClick(position, event, cancelAction = false) {
|
||||
if (!this.root.gameInitialized) {
|
||||
logger.warn("Skipping click outside of game initiaization!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.root.hud.hasBlockingOverlayOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.interceptors.length; ++i) {
|
||||
const interceptor = this.interceptors[i];
|
||||
if (interceptor.onCanvasClick(position, cancelAction) === STOP_PROPAGATION) {
|
||||
// log(this, "Interceptor", interceptor.constructor.name, "catched click");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCanvasRightClick(position, event) {
|
||||
this.onCanvasClick(position, event, true);
|
||||
}
|
||||
}
|
||||
167
src/js/game/colors.js
Normal file
167
src/js/game/colors.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/** @enum {string} */
|
||||
export const enumColors = {
|
||||
red: "red",
|
||||
green: "green",
|
||||
blue: "blue",
|
||||
|
||||
yellow: "yellow",
|
||||
purple: "purple",
|
||||
cyan: "cyan",
|
||||
|
||||
white: "white",
|
||||
uncolored: "uncolored",
|
||||
};
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumColorToShortcode = {
|
||||
[enumColors.red]: "r",
|
||||
[enumColors.green]: "g",
|
||||
[enumColors.blue]: "b",
|
||||
|
||||
[enumColors.yellow]: "y",
|
||||
[enumColors.purple]: "p",
|
||||
[enumColors.cyan]: "c",
|
||||
|
||||
[enumColors.white]: "w",
|
||||
[enumColors.uncolored]: "u",
|
||||
};
|
||||
|
||||
/** @enum {enumColors} */
|
||||
export const enumShortcodeToColor = {};
|
||||
for (const key in enumColorToShortcode) {
|
||||
enumShortcodeToColor[enumColorToShortcode[key]] = key;
|
||||
}
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumColorsToHexCode = {
|
||||
[enumColors.red]: "#ff666a",
|
||||
[enumColors.green]: "#78ff66",
|
||||
[enumColors.blue]: "#66a7ff",
|
||||
|
||||
// red + green
|
||||
[enumColors.yellow]: "#fcf52a",
|
||||
|
||||
// red + blue
|
||||
[enumColors.purple]: "#dd66ff",
|
||||
|
||||
// blue + green
|
||||
[enumColors.cyan]: "#87fff5",
|
||||
|
||||
// blue + green + red
|
||||
[enumColors.white]: "#ffffff",
|
||||
|
||||
[enumColors.uncolored]: "#aaaaaa",
|
||||
};
|
||||
|
||||
const c = enumColors;
|
||||
/** @enum {Object.<string, string>} */
|
||||
export const enumColorMixingResults = {
|
||||
// 255, 0, 0
|
||||
[c.red]: {
|
||||
[c.green]: c.yellow,
|
||||
[c.blue]: c.purple,
|
||||
|
||||
[c.yellow]: c.yellow,
|
||||
[c.purple]: c.purple,
|
||||
[c.cyan]: c.white,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 0
|
||||
[c.green]: {
|
||||
[c.blue]: c.cyan,
|
||||
|
||||
[c.yellow]: c.yellow,
|
||||
[c.purple]: c.white,
|
||||
[c.cyan]: c.cyan,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 0
|
||||
[c.blue]: {
|
||||
[c.yellow]: c.white,
|
||||
[c.purple]: c.purple,
|
||||
[c.cyan]: c.cyan,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 255, 255, 0
|
||||
[c.yellow]: {
|
||||
[c.purple]: c.white,
|
||||
[c.cyan]: c.white,
|
||||
},
|
||||
|
||||
// 255, 0, 255
|
||||
[c.purple]: {
|
||||
[c.cyan]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 255
|
||||
[c.cyan]: {},
|
||||
|
||||
//// SPECIAL COLORS
|
||||
|
||||
// 255, 255, 255
|
||||
[c.white]: {
|
||||
// auto
|
||||
},
|
||||
|
||||
// X, X, X
|
||||
[c.uncolored]: {
|
||||
// auto
|
||||
},
|
||||
};
|
||||
|
||||
// Create same color lookups
|
||||
for (const color in enumColors) {
|
||||
enumColorMixingResults[color][color] = color;
|
||||
|
||||
// Anything with white is white again
|
||||
enumColorMixingResults[color][c.white] = c.white;
|
||||
|
||||
// Anything with uncolored is the same color
|
||||
enumColorMixingResults[color][c.uncolored] = color;
|
||||
}
|
||||
|
||||
// Create reverse lookup and check color mixing lookups
|
||||
for (const colorA in enumColorMixingResults) {
|
||||
for (const colorB in enumColorMixingResults[colorA]) {
|
||||
const resultColor = enumColorMixingResults[colorA][colorB];
|
||||
if (!enumColorMixingResults[colorB]) {
|
||||
enumColorMixingResults[colorB] = {
|
||||
[colorA]: resultColor,
|
||||
};
|
||||
} else {
|
||||
const existingResult = enumColorMixingResults[colorB][colorA];
|
||||
if (existingResult && existingResult !== resultColor) {
|
||||
assertAlways(
|
||||
false,
|
||||
"invalid color mixing configuration, " +
|
||||
colorA +
|
||||
" + " +
|
||||
colorB +
|
||||
" is " +
|
||||
resultColor +
|
||||
" but " +
|
||||
colorB +
|
||||
" + " +
|
||||
colorA +
|
||||
" is " +
|
||||
existingResult
|
||||
);
|
||||
}
|
||||
enumColorMixingResults[colorB][colorA] = resultColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const colorA in enumColorMixingResults) {
|
||||
for (const colorB in enumColorMixingResults) {
|
||||
if (!enumColorMixingResults[colorA][colorB]) {
|
||||
assertAlways(false, "Color mixing of", colorA, "with", colorB, "is not defined");
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/js/game/component.js
Normal file
38
src/js/game/component.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { BasicSerializableObject } from "../savegame/serialization";
|
||||
|
||||
export class Component extends BasicSerializableObject {
|
||||
/**
|
||||
* Returns the components unique id
|
||||
* @returns {string}
|
||||
*/
|
||||
static getId() {
|
||||
abstract;
|
||||
return "unknown-component";
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the schema used for serialization
|
||||
*/
|
||||
static getSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/* dev:start */
|
||||
|
||||
/**
|
||||
* Fixes typeof DerivedComponent is not assignable to typeof Component, compiled out
|
||||
* in non-dev builds
|
||||
*/
|
||||
constructor(...args) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representing the components data, only in dev builds
|
||||
* @returns {string}
|
||||
*/
|
||||
getDebugString() {
|
||||
return null;
|
||||
}
|
||||
/* dev:end */
|
||||
}
|
||||
38
src/js/game/component_registry.js
Normal file
38
src/js/game/component_registry.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { gComponentRegistry } from "../core/global_registries";
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
import { BeltComponent } from "./components/belt";
|
||||
import { ItemEjectorComponent } from "./components/item_ejector";
|
||||
import { ItemAcceptorComponent } from "./components/item_acceptor";
|
||||
import { MinerComponent } from "./components/miner";
|
||||
import { ItemProcessorComponent } from "./components/item_processor";
|
||||
import { ReplaceableMapEntityComponent } from "./components/replaceable_map_entity";
|
||||
import { UndergroundBeltComponent } from "./components/underground_belt";
|
||||
import { UnremovableComponent } from "./components/unremovable";
|
||||
import { HubComponent } from "./components/hub";
|
||||
|
||||
export function initComponentRegistry() {
|
||||
gComponentRegistry.register(StaticMapEntityComponent);
|
||||
gComponentRegistry.register(BeltComponent);
|
||||
gComponentRegistry.register(ItemEjectorComponent);
|
||||
gComponentRegistry.register(ItemAcceptorComponent);
|
||||
gComponentRegistry.register(MinerComponent);
|
||||
gComponentRegistry.register(ItemProcessorComponent);
|
||||
gComponentRegistry.register(ReplaceableMapEntityComponent);
|
||||
gComponentRegistry.register(UndergroundBeltComponent);
|
||||
gComponentRegistry.register(UnremovableComponent);
|
||||
gComponentRegistry.register(HubComponent);
|
||||
|
||||
// IMPORTANT ^^^^^ REGENERATE SAVEGAME SCHEMA AFTERWARDS
|
||||
// IMPORTANT ^^^^^ ALSO UPDATE ENTITY COMPONENT STORAG
|
||||
|
||||
// Sanity check - If this is thrown, you (=me, lol) forgot to add a new component here
|
||||
|
||||
assert(
|
||||
// @ts-ignore
|
||||
require.context("./components", false, /.*\.js/i).keys().length ===
|
||||
gComponentRegistry.getNumEntries(),
|
||||
"Not all components are registered"
|
||||
);
|
||||
|
||||
console.log("📦 There are", gComponentRegistry.getNumEntries(), "components");
|
||||
}
|
||||
92
src/js/game/components/belt.js
Normal file
92
src/js/game/components/belt.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Component } from "../component";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { gItemRegistry } from "../../core/global_registries";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Vector, enumDirection } from "../../core/vector";
|
||||
import { Math_PI, Math_sin, Math_cos } from "../../core/builtins";
|
||||
import { globalConfig } from "../../core/config";
|
||||
|
||||
export class BeltComponent extends Component {
|
||||
static getId() {
|
||||
return "Belt";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
direction: types.string,
|
||||
sortedItems: types.array(types.pair(types.ufloat, types.obj(gItemRegistry))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumDirection=} param0.direction The direction of the belt
|
||||
*/
|
||||
constructor({ direction = enumDirection.top }) {
|
||||
super();
|
||||
|
||||
this.direction = direction;
|
||||
|
||||
/** @type {Array<[number, BaseItem]>} */
|
||||
this.sortedItems = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from belt space (0 = start of belt ... 1 = end of belt) to the local
|
||||
* belt coordinates (-0.5|-0.5 to 0.5|0.5)
|
||||
* @param {number} progress
|
||||
* @returns {Vector}
|
||||
*/
|
||||
transformBeltToLocalSpace(progress) {
|
||||
switch (this.direction) {
|
||||
case enumDirection.top:
|
||||
return new Vector(0, 0.5 - progress);
|
||||
|
||||
case enumDirection.right: {
|
||||
const arcProgress = progress * 0.5 * Math_PI;
|
||||
return new Vector(0.5 - 0.5 * Math_cos(arcProgress), 0.5 - 0.5 * Math_sin(arcProgress));
|
||||
}
|
||||
case enumDirection.left: {
|
||||
const arcProgress = progress * 0.5 * Math_PI;
|
||||
return new Vector(-0.5 + 0.5 * Math_cos(arcProgress), 0.5 - 0.5 * Math_sin(arcProgress));
|
||||
}
|
||||
default:
|
||||
assertAlways(false, "Invalid belt direction: " + this.direction);
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the belt can currently accept an item from the given direction
|
||||
* @param {enumDirection} direction
|
||||
*/
|
||||
canAcceptNewItem(direction) {
|
||||
const firstItem = this.sortedItems[0];
|
||||
if (!firstItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return firstItem[0] > globalConfig.itemSpacingOnBelts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a new item to the belt
|
||||
* @param {BaseItem} item
|
||||
* @param {enumDirection} direction
|
||||
*/
|
||||
takeNewItem(item, direction) {
|
||||
this.sortedItems.unshift([0, item]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how much space there is to the first item
|
||||
*/
|
||||
getDistanceToFirstItemCenter() {
|
||||
const firstItem = this.sortedItems[0];
|
||||
if (!firstItem) {
|
||||
return 1;
|
||||
}
|
||||
return firstItem[0];
|
||||
}
|
||||
}
|
||||
25
src/js/game/components/hub.js
Normal file
25
src/js/game/components/hub.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Component } from "../component";
|
||||
import { ShapeDefinition } from "../shape_definition";
|
||||
|
||||
export class HubComponent extends Component {
|
||||
static getId() {
|
||||
return "Hub";
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
/**
|
||||
* Shape definitions in queue to be analyzed and counted towards the goal
|
||||
* @type {Array<ShapeDefinition>}
|
||||
*/
|
||||
this.definitionsToAnalyze = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
queueShapeDefinition(definition) {
|
||||
this.definitionsToAnalyze.push(definition);
|
||||
}
|
||||
}
|
||||
129
src/js/game/components/item_acceptor.js
Normal file
129
src/js/game/components/item_acceptor.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Component } from "../component";
|
||||
import { Vector, enumDirection, enumDirectionToAngle, enumInvertedDirections } from "../../core/vector";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { ShapeItem } from "../items/shape_item";
|
||||
import { ColorItem } from "../items/color_item";
|
||||
|
||||
/**
|
||||
* @enum {string?}
|
||||
*/
|
||||
export const enumItemAcceptorItemFilter = {
|
||||
shape: "shape",
|
||||
color: "color",
|
||||
none: null,
|
||||
};
|
||||
|
||||
/** @typedef {{
|
||||
* pos: Vector,
|
||||
* directions: enumDirection[],
|
||||
* filter?: enumItemAcceptorItemFilter
|
||||
* }} ItemAcceptorSlot */
|
||||
|
||||
export class ItemAcceptorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemAcceptor";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// slots: "TODO",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} param0.slots The slots from which we accept items
|
||||
*/
|
||||
constructor({ slots }) {
|
||||
super();
|
||||
|
||||
this.setSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} slots
|
||||
*/
|
||||
setSlots(slots) {
|
||||
/** @type {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} */
|
||||
this.slots = [];
|
||||
for (let i = 0; i < slots.length; ++i) {
|
||||
const slot = slots[i];
|
||||
this.slots.push({
|
||||
pos: slot.pos,
|
||||
directions: slot.directions,
|
||||
|
||||
// Which type of item to accept (shape | color | all) @see enumItemAcceptorItemFilter
|
||||
filter: slot.filter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this acceptor can accept a new item at slot N
|
||||
* @param {number} slotIndex
|
||||
* @param {BaseItem=} item
|
||||
*/
|
||||
canAcceptItem(slotIndex, item) {
|
||||
const slot = this.slots[slotIndex];
|
||||
switch (slot.filter) {
|
||||
case enumItemAcceptorItemFilter.shape: {
|
||||
return item instanceof ShapeItem;
|
||||
}
|
||||
case enumItemAcceptorItemFilter.color: {
|
||||
return item instanceof ColorItem;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find a slot which accepts the current item
|
||||
* @param {Vector} targetLocalTile
|
||||
* @param {enumDirection} fromLocalDirection
|
||||
* @returns {{
|
||||
* slot: ItemAcceptorSlot,
|
||||
* index: number,
|
||||
* acceptedDirection: enumDirection
|
||||
* }|null}
|
||||
*/
|
||||
findMatchingSlot(targetLocalTile, fromLocalDirection) {
|
||||
// We need to invert our direction since the acceptor specifies *from* which direction
|
||||
// it accepts items, but the ejector specifies *into* which direction it ejects items.
|
||||
// E.g.: Ejector ejects into "right" direction but acceptor accepts from "left" direction.
|
||||
const desiredDirection = enumInvertedDirections[fromLocalDirection];
|
||||
|
||||
// Go over all slots and try to find a target slot
|
||||
for (let slotIndex = 0; slotIndex < this.slots.length; ++slotIndex) {
|
||||
const slot = this.slots[slotIndex];
|
||||
|
||||
// const acceptorLocalPosition = targetStaticComp.applyRotationToVector(
|
||||
// slot.pos
|
||||
// );
|
||||
|
||||
// const acceptorGlobalPosition = acceptorLocalPosition.add(targetStaticComp.origin);
|
||||
|
||||
// Make sure the acceptor slot is on the right position
|
||||
if (!slot.pos.equals(targetLocalTile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the acceptor slot accepts items from our direction
|
||||
for (let i = 0; i < slot.directions.length; ++i) {
|
||||
// const localDirection = targetStaticComp.localDirectionToWorld(slot.directions[l]);
|
||||
if (desiredDirection === slot.directions[i]) {
|
||||
return {
|
||||
slot,
|
||||
index: slotIndex,
|
||||
acceptedDirection: desiredDirection,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// && this.canAcceptItem(slotIndex, ejectingItem)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
162
src/js/game/components/item_ejector.js
Normal file
162
src/js/game/components/item_ejector.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { Vector, enumDirection, enumDirectionToVector } from "../../core/vector";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* pos: Vector,
|
||||
* direction: enumDirection,
|
||||
* item: BaseItem,
|
||||
* progress: number?
|
||||
* }} ItemEjectorSlot
|
||||
*/
|
||||
|
||||
export class ItemEjectorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemEjector";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// slots: "TODO"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>} param0.slots The slots to eject on
|
||||
* @param {boolean=} param0.instantEject If the ejection is instant
|
||||
*/
|
||||
constructor({ slots, instantEject = false }) {
|
||||
super();
|
||||
|
||||
// How long items take to eject
|
||||
this.instantEject = instantEject;
|
||||
|
||||
this.setSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>} slots The slots to eject on
|
||||
*/
|
||||
setSlots(slots) {
|
||||
/** @type {Array<ItemEjectorSlot>} */
|
||||
this.slots = [];
|
||||
for (let i = 0; i < slots.length; ++i) {
|
||||
const slot = slots[i];
|
||||
this.slots.push({
|
||||
pos: slot.pos,
|
||||
direction: slot.direction,
|
||||
item: null,
|
||||
progress: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of slots
|
||||
*/
|
||||
getNumSlots() {
|
||||
return this.slots.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns where this slot ejects to
|
||||
* @param {number} index
|
||||
* @returns {Vector}
|
||||
*/
|
||||
getSlotTargetLocalTile(index) {
|
||||
const slot = this.slots[index];
|
||||
const directionVector = enumDirectionToVector[slot.direction];
|
||||
return slot.pos.add(directionVector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether any slot ejects to the given local tile
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
anySlotEjectsToLocalTile(tile) {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.getSlotTargetLocalTile(i).equals(tile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if slot # is currently ejecting
|
||||
* @param {number} slotIndex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSlotEjecting(slotIndex) {
|
||||
assert(slotIndex >= 0 && slotIndex < this.slots.length, "Invalid ejector slot: " + slotIndex);
|
||||
return !!this.slots[slotIndex].item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can eject on a given slot
|
||||
* @param {number} slotIndex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canEjectOnSlot(slotIndex) {
|
||||
assert(slotIndex >= 0 && slotIndex < this.slots.length, "Invalid ejector slot: " + slotIndex);
|
||||
return !this.slots[slotIndex].item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first free slot on this ejector or null if there is none
|
||||
* @returns {number?}
|
||||
*/
|
||||
getFirstFreeSlot() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.canEjectOnSlot(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if any slot is ejecting
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAnySlotEjecting() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.slots[i].item) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if any slot is free
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasAnySlotFree() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.canEjectOnSlot(i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to eject a given item
|
||||
* @param {number} slotIndex
|
||||
* @param {BaseItem} item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
tryEject(slotIndex, item) {
|
||||
if (!this.canEjectOnSlot(slotIndex)) {
|
||||
return false;
|
||||
}
|
||||
this.slots[slotIndex].item = item;
|
||||
this.slots[slotIndex].progress = this.instantEject ? 1 : 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
106
src/js/game/components/item_processor.js
Normal file
106
src/js/game/components/item_processor.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumItemProcessorTypes = {
|
||||
splitter: "splitter",
|
||||
cutter: "cutter",
|
||||
rotater: "rotater",
|
||||
stacker: "stacker",
|
||||
trash: "trash",
|
||||
mixer: "mixer",
|
||||
painter: "painter",
|
||||
hub: "hub",
|
||||
};
|
||||
|
||||
export class ItemProcessorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemProcessor";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// TODO
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumItemProcessorTypes} param0.processorType Which type of processor this is
|
||||
* @param {number} param0.inputsPerCharge How many items this machine needs until it can start working
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>=} param0.beltUnderlays Where to render belt underlays
|
||||
*
|
||||
*/
|
||||
constructor({ processorType = enumItemProcessorTypes.splitter, inputsPerCharge, beltUnderlays = [] }) {
|
||||
super();
|
||||
|
||||
// Which slot to emit next, this is only a preference and if it can't emit
|
||||
// it will take the other one. Some machines ignore this (e.g. the splitter) to make
|
||||
// sure the outputs always match
|
||||
this.nextOutputSlot = 0;
|
||||
|
||||
// Type of the processor
|
||||
this.type = processorType;
|
||||
|
||||
// How many inputs we need for one charge
|
||||
this.inputsPerCharge = inputsPerCharge;
|
||||
|
||||
// Which belt underlays to render
|
||||
this.beltUnderlays = beltUnderlays;
|
||||
|
||||
/**
|
||||
* Our current inputs
|
||||
* @type {Array<{ item: BaseItem, sourceSlot: number }>}
|
||||
*/
|
||||
this.inputSlots = [];
|
||||
|
||||
/**
|
||||
* What we are currently processing, empty if we don't produce anything rn
|
||||
* requiredSlot: Item *must* be ejected on this slot
|
||||
* preferredSlot: Item *can* be ejected on this slot, but others are fine too if the one is not usable
|
||||
* @type {Array<{item: BaseItem, requiredSlot?: number, preferredSlot?: number}>}
|
||||
*/
|
||||
this.itemsToEject = [];
|
||||
|
||||
/**
|
||||
* How long it takes until we are done with the current items
|
||||
*/
|
||||
this.secondsUntilEject = 0;
|
||||
|
||||
/**
|
||||
* Fixes belt animations
|
||||
* @type {Array<{ item: BaseItem, slotIndex: number, animProgress: number, direction: enumDirection}>}
|
||||
*/
|
||||
this.itemConsumptionAnimations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to take the item
|
||||
* @param {BaseItem} item
|
||||
*/
|
||||
tryTakeItem(item, sourceSlot, sourceDirection) {
|
||||
if (this.inputSlots.length >= this.inputsPerCharge) {
|
||||
// Already full
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that we only take one item per slot
|
||||
for (let i = 0; i < this.inputSlots.length; ++i) {
|
||||
const slot = this.inputSlots[i];
|
||||
if (slot.sourceSlot === sourceSlot) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.inputSlots.push({ item, sourceSlot });
|
||||
this.itemConsumptionAnimations.push({
|
||||
item,
|
||||
slotIndex: sourceSlot,
|
||||
direction: sourceDirection,
|
||||
animProgress: 0.0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
23
src/js/game/components/miner.js
Normal file
23
src/js/game/components/miner.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { Component } from "../component";
|
||||
|
||||
export class MinerComponent extends Component {
|
||||
static getId() {
|
||||
return "Miner";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
lastMiningTime: types.ufloat,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} param0
|
||||
*/
|
||||
constructor({}) {
|
||||
super();
|
||||
this.lastMiningTime = 0;
|
||||
}
|
||||
}
|
||||
11
src/js/game/components/replaceable_map_entity.js
Normal file
11
src/js/game/components/replaceable_map_entity.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from "../component";
|
||||
|
||||
/**
|
||||
* Marks an entity as replaceable, so that when other buildings are placed above him it
|
||||
* simply gets deleted
|
||||
*/
|
||||
export class ReplaceableMapEntityComponent extends Component {
|
||||
static getId() {
|
||||
return "ReplaceableMapEntity";
|
||||
}
|
||||
}
|
||||
184
src/js/game/components/static_map_entity.js
Normal file
184
src/js/game/components/static_map_entity.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Math_radians } from "../../core/builtins";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { Rectangle } from "../../core/rectangle";
|
||||
import { AtlasSprite } from "../../core/sprites";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { Component } from "../component";
|
||||
|
||||
export class StaticMapEntityComponent extends Component {
|
||||
static getId() {
|
||||
return "StaticMapEntity";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
origin: types.tileVector,
|
||||
tileSize: types.tileVector,
|
||||
rotationDegrees: types.uint,
|
||||
spriteKey: types.string,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Vector=} param0.origin Origin (Top Left corner) of the entity
|
||||
* @param {Vector=} param0.tileSize Size of the entity in tiles
|
||||
* @param {number=} param0.rotationDegrees Rotation in degrees. Must be multiple of 90
|
||||
* @param {string=} param0.spriteKey Optional sprite
|
||||
* @param {string=} param0.silhouetteColor Optional silhouette color override
|
||||
*/
|
||||
constructor({
|
||||
origin = new Vector(),
|
||||
tileSize = new Vector(1, 1),
|
||||
rotationDegrees = 0,
|
||||
spriteKey = null,
|
||||
silhouetteColor = null,
|
||||
}) {
|
||||
super();
|
||||
assert(
|
||||
rotationDegrees % 90 === 0,
|
||||
"Rotation of static map entity must be multiple of 90 (was " + rotationDegrees + ")"
|
||||
);
|
||||
|
||||
this.origin = origin;
|
||||
this.tileSize = tileSize;
|
||||
this.spriteKey = spriteKey;
|
||||
this.rotationDegrees = rotationDegrees;
|
||||
this.silhouetteColor = silhouetteColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective rectangle of this entity in tile space
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
getTileSpaceBounds() {
|
||||
switch (this.rotationDegrees) {
|
||||
case 0:
|
||||
return new Rectangle(this.origin.x, this.origin.y, this.tileSize.x, this.tileSize.y);
|
||||
case 90:
|
||||
return new Rectangle(
|
||||
this.origin.x - this.tileSize.y + 1,
|
||||
this.origin.y,
|
||||
this.tileSize.y,
|
||||
this.tileSize.x
|
||||
);
|
||||
case 180:
|
||||
return new Rectangle(
|
||||
this.origin.x - this.tileSize.x + 1,
|
||||
this.origin.y - this.tileSize.y + 1,
|
||||
this.tileSize.x,
|
||||
this.tileSize.y
|
||||
);
|
||||
case 270:
|
||||
return new Rectangle(
|
||||
this.origin.x,
|
||||
this.origin.y - this.tileSize.x + 1,
|
||||
this.tileSize.y,
|
||||
this.tileSize.x
|
||||
);
|
||||
default:
|
||||
assert(false, "Invalid rotation");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given vector/rotation from local space to world space
|
||||
* @param {Vector} vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
applyRotationToVector(vector) {
|
||||
return vector.rotateFastMultipleOf90(this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given vector/rotation from world space to local space
|
||||
* @param {Vector} vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
unapplyRotationToVector(vector) {
|
||||
return vector.rotateFastMultipleOf90(360 - this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given direction from local space
|
||||
* @param {enumDirection} direction
|
||||
* @returns {enumDirection}
|
||||
*/
|
||||
localDirectionToWorld(direction) {
|
||||
return Vector.transformDirectionFromMultipleOf90(direction, this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given direction from world to local space
|
||||
* @param {enumDirection} direction
|
||||
* @returns {enumDirection}
|
||||
*/
|
||||
worldDirectionToLocal(direction) {
|
||||
return Vector.transformDirectionFromMultipleOf90(direction, 360 - this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms from local tile space to global tile space
|
||||
* @param {Vector} localTile
|
||||
* @returns {Vector}
|
||||
*/
|
||||
localTileToWorld(localTile) {
|
||||
const result = this.applyRotationToVector(localTile);
|
||||
result.addInplace(this.origin);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms from world space to local space
|
||||
* @param {Vector} worldTile
|
||||
*/
|
||||
worldToLocalTile(worldTile) {
|
||||
const localUnrotated = worldTile.sub(this.origin);
|
||||
return this.unapplyRotationToVector(localUnrotated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a sprite over the whole space of the entity
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {AtlasSprite} sprite
|
||||
* @param {number=} extrudePixels How many pixels to extrude the sprite
|
||||
* @param {boolean=} clipping Whether to clip
|
||||
*/
|
||||
drawSpriteOnFullEntityBounds(parameters, sprite, extrudePixels = 0, clipping = true) {
|
||||
const worldX = this.origin.x * globalConfig.tileSize;
|
||||
const worldY = this.origin.y * globalConfig.tileSize;
|
||||
|
||||
if (this.rotationDegrees === 0) {
|
||||
// Early out, is faster
|
||||
sprite.drawCached(
|
||||
parameters,
|
||||
worldX - extrudePixels * this.tileSize.x,
|
||||
worldY - extrudePixels * this.tileSize.y,
|
||||
globalConfig.tileSize * this.tileSize.x + 2 * extrudePixels * this.tileSize.x,
|
||||
globalConfig.tileSize * this.tileSize.y + 2 * extrudePixels * this.tileSize.y,
|
||||
clipping
|
||||
);
|
||||
} else {
|
||||
const rotationCenterX = worldX + globalConfig.halfTileSize;
|
||||
const rotationCenterY = worldY + globalConfig.halfTileSize;
|
||||
|
||||
parameters.context.translate(rotationCenterX, rotationCenterY);
|
||||
parameters.context.rotate(Math_radians(this.rotationDegrees));
|
||||
|
||||
sprite.drawCached(
|
||||
parameters,
|
||||
-globalConfig.halfTileSize - extrudePixels * this.tileSize.x,
|
||||
-globalConfig.halfTileSize - extrudePixels * this.tileSize.y,
|
||||
globalConfig.tileSize * this.tileSize.x + 2 * extrudePixels * this.tileSize.x,
|
||||
globalConfig.tileSize * this.tileSize.y + 2 * extrudePixels * this.tileSize.y,
|
||||
false
|
||||
);
|
||||
|
||||
parameters.context.rotate(-Math_radians(this.rotationDegrees));
|
||||
parameters.context.translate(-rotationCenterX, -rotationCenterY);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/js/game/components/underground_belt.js
Normal file
88
src/js/game/components/underground_belt.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
import { globalConfig } from "../../core/config";
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumUndergroundBeltMode = {
|
||||
sender: "sender",
|
||||
receiver: "receiver",
|
||||
};
|
||||
|
||||
export class UndergroundBeltComponent extends Component {
|
||||
static getId() {
|
||||
return "UndergroundBelt";
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumUndergroundBeltMode=} param0.mode As which type of belt the entity acts
|
||||
*/
|
||||
constructor({ mode = enumUndergroundBeltMode.sender }) {
|
||||
super();
|
||||
|
||||
this.mode = mode;
|
||||
|
||||
/**
|
||||
* Used on both receiver and sender.
|
||||
* Reciever: Used to store the next item to transfer, and to block input while doing this
|
||||
* Sender: Used to store which items are currently "travelling"
|
||||
* @type {Array<[BaseItem, number]>} Format is [Item, remaining seconds until transfer/ejection]
|
||||
*/
|
||||
this.pendingItems = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to accept an item from an external source like a regular belt or building
|
||||
* @param {BaseItem} item
|
||||
* @param {number} beltSpeed How fast this item travels
|
||||
*/
|
||||
tryAcceptExternalItem(item, beltSpeed) {
|
||||
if (this.mode !== enumUndergroundBeltMode.sender) {
|
||||
// Only senders accept external items
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.pendingItems.length > 0) {
|
||||
// We currently have a pending item
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Takes", 1 / beltSpeed);
|
||||
this.pendingItems.push([item, 1 / beltSpeed]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to accept a tunneled item
|
||||
* @param {BaseItem} item
|
||||
* @param {number} travelDistance How many tiles this item has to travel
|
||||
* @param {number} beltSpeed How fast this item travels
|
||||
*/
|
||||
tryAcceptTunneledItem(item, travelDistance, beltSpeed) {
|
||||
if (this.mode !== enumUndergroundBeltMode.receiver) {
|
||||
// Only receivers can accept tunneled items
|
||||
return false;
|
||||
}
|
||||
|
||||
// Notice: We assume that for all items the travel distance is the same
|
||||
const maxItemsInTunnel = (1 + travelDistance) / globalConfig.itemSpacingOnBelts;
|
||||
if (this.pendingItems.length >= maxItemsInTunnel) {
|
||||
// Simulate a real belt which gets full at some point
|
||||
return false;
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// This corresponds to the item ejector - it needs 0.5 additional tiles to eject the item.
|
||||
// So instead of adding 1 we add 0.5 only.
|
||||
const travelDuration = (travelDistance + 0.5) / beltSpeed;
|
||||
console.log(travelDistance, "->", travelDuration);
|
||||
|
||||
this.pendingItems.push([item, travelDuration]);
|
||||
|
||||
// Sort so we can only look at the first ones
|
||||
this.pendingItems.sort((a, b) => a[1] - b[1]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
7
src/js/game/components/unremovable.js
Normal file
7
src/js/game/components/unremovable.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Component } from "../component";
|
||||
|
||||
export class UnremovableComponent extends Component {
|
||||
static getId() {
|
||||
return "Unremovable";
|
||||
}
|
||||
}
|
||||
434
src/js/game/core.js
Normal file
434
src/js/game/core.js
Normal file
@@ -0,0 +1,434 @@
|
||||
/* typehints:start */
|
||||
import { InGameState } from "../states/ingame";
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { BufferMaintainer } from "../core/buffer_maintainer";
|
||||
import { disableImageSmoothing, enableImageSmoothing, registerCanvas } from "../core/buffer_utils";
|
||||
import { Math_random } from "../core/builtins";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { getDeviceDPI, resizeHighDPICanvas } from "../core/dpi_manager";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { gMetaBuildingRegistry } from "../core/global_registries";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { PerlinNoise } from "../core/perlin_noise";
|
||||
import { Vector } from "../core/vector";
|
||||
import { Savegame } from "../savegame/savegame";
|
||||
import { SavegameSerializer } from "../savegame/savegame_serializer";
|
||||
import { AutomaticSave } from "./automatic_save";
|
||||
import { MetaHubBuilding } from "./buildings/hub";
|
||||
import { Camera } from "./camera";
|
||||
import { CanvasClickInterceptor } from "./canvas_click_interceptor";
|
||||
import { EntityManager } from "./entity_manager";
|
||||
import { GameSystemManager } from "./game_system_manager";
|
||||
import { HubGoals } from "./hub_goals";
|
||||
import { GameHUD } from "./hud/hud";
|
||||
import { KeyActionMapper } from "./key_action_mapper";
|
||||
import { GameLogic } from "./logic";
|
||||
import { MapView } from "./map_view";
|
||||
import { GameRoot } from "./root";
|
||||
import { ShapeDefinitionManager } from "./shape_definition_manager";
|
||||
import { SoundProxy } from "./sound_proxy";
|
||||
import { GameTime } from "./time/game_time";
|
||||
|
||||
const logger = createLogger("ingame/core");
|
||||
|
||||
// Store the canvas so we can reuse it later
|
||||
/** @type {HTMLCanvasElement} */
|
||||
let lastCanvas = null;
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let lastContext = null;
|
||||
|
||||
/**
|
||||
* The core manages the root and represents the whole game. It wraps the root, since
|
||||
* the root class is just a data holder.
|
||||
*/
|
||||
export class GameCore {
|
||||
/** @param {Application} app */
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @type {GameRoot} */
|
||||
this.root = null;
|
||||
|
||||
/**
|
||||
* Time budget (seconds) for logic updates
|
||||
*/
|
||||
this.logicTimeBudget = 0;
|
||||
|
||||
/**
|
||||
* Time budget (seconds) for user interface updates
|
||||
*/
|
||||
this.uiTimeBudget = 0;
|
||||
|
||||
/**
|
||||
* Set to true at the beginning of a logic update and cleared when its finished.
|
||||
* This is to prevent doing a recursive logic update which can lead to unexpected
|
||||
* behaviour.
|
||||
*/
|
||||
this.duringLogicUpdate = false;
|
||||
|
||||
// Cached
|
||||
this.boundInternalTick = this.updateLogic.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the root object which stores all game related data. The state
|
||||
* is required as a back reference (used sometimes)
|
||||
* @param {InGameState} parentState
|
||||
* @param {Savegame} savegame
|
||||
*/
|
||||
initializeRoot(parentState, savegame) {
|
||||
// Construct the root element, this is the data representation of the game
|
||||
this.root = new GameRoot(this.app);
|
||||
this.root.gameState = parentState;
|
||||
this.root.savegame = savegame;
|
||||
this.root.gameWidth = this.app.screenWidth;
|
||||
this.root.gameHeight = this.app.screenHeight;
|
||||
|
||||
// Initialize canvas element & context
|
||||
this.internalInitCanvas();
|
||||
|
||||
// Members
|
||||
const root = this.root;
|
||||
|
||||
// This isn't nice, but we need it right here
|
||||
root.gameState.keyActionMapper = new KeyActionMapper(root, this.root.gameState.inputReciever);
|
||||
|
||||
// Init classes
|
||||
root.camera = new Camera(root);
|
||||
root.map = new MapView(root);
|
||||
root.logic = new GameLogic(root);
|
||||
root.hud = new GameHUD(root);
|
||||
root.time = new GameTime(root);
|
||||
root.canvasClickInterceptor = new CanvasClickInterceptor(root);
|
||||
root.automaticSave = new AutomaticSave(root);
|
||||
root.soundProxy = new SoundProxy(root);
|
||||
|
||||
// Init managers
|
||||
root.entityMgr = new EntityManager(root);
|
||||
root.systemMgr = new GameSystemManager(root);
|
||||
root.shapeDefinitionMgr = new ShapeDefinitionManager(root);
|
||||
root.mapNoiseGenerator = new PerlinNoise(Math.random()); // TODO: Save seed
|
||||
root.hubGoals = new HubGoals(root);
|
||||
root.buffers = new BufferMaintainer(root);
|
||||
|
||||
// root.particleMgr = new ParticleManager(root);
|
||||
// root.uiParticleMgr = new ParticleManager(root);
|
||||
|
||||
// Initialize the hud once everything is loaded
|
||||
this.root.hud.initialize();
|
||||
|
||||
// Initial resize event, it might be possible that the screen
|
||||
// resized later during init tho, which is why will emit it later
|
||||
// again anyways
|
||||
this.resize(this.app.screenWidth, this.app.screenHeight);
|
||||
|
||||
if (G_IS_DEV) {
|
||||
// @ts-ignore
|
||||
window.globalRoot = root;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new game, this means creating a new map and centering on the
|
||||
* plaerbase
|
||||
* */
|
||||
initNewGame() {
|
||||
logger.log("Initializing new game");
|
||||
this.root.gameIsFresh = true;
|
||||
|
||||
gMetaBuildingRegistry
|
||||
.findByClass(MetaHubBuilding)
|
||||
.createAndPlaceEntity(this.root, new Vector(-2, -2), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inits an existing game by loading the raw savegame data and deserializing it.
|
||||
* Also runs basic validity checks.
|
||||
*/
|
||||
initExistingGame() {
|
||||
logger.log("Initializing existing game");
|
||||
const serializer = new SavegameSerializer();
|
||||
|
||||
try {
|
||||
const status = serializer.deserialize(this.root.savegame.getCurrentDump(), this.root);
|
||||
if (!status.isGood()) {
|
||||
logger.error("savegame-deserialize-failed:" + status.reason);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.error("Exception during deserialization:", ex);
|
||||
return false;
|
||||
}
|
||||
this.root.gameIsFresh = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the render canvas
|
||||
*/
|
||||
internalInitCanvas() {
|
||||
let canvas, context;
|
||||
if (!lastCanvas) {
|
||||
logger.log("Creating new canvas");
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.id = "ingame_Canvas";
|
||||
canvas.setAttribute("opaque", "true");
|
||||
canvas.setAttribute("webkitOpaque", "true");
|
||||
canvas.setAttribute("mozOpaque", "true");
|
||||
this.root.gameState.getDivElement().appendChild(canvas);
|
||||
context = canvas.getContext("2d", { alpha: false });
|
||||
|
||||
lastCanvas = canvas;
|
||||
lastContext = context;
|
||||
} else {
|
||||
logger.log("Reusing canvas");
|
||||
if (lastCanvas.parentElement) {
|
||||
lastCanvas.parentElement.removeChild(lastCanvas);
|
||||
}
|
||||
this.root.gameState.getDivElement().appendChild(lastCanvas);
|
||||
|
||||
canvas = lastCanvas;
|
||||
context = lastContext;
|
||||
|
||||
lastContext.clearRect(0, 0, lastCanvas.width, lastCanvas.height);
|
||||
}
|
||||
|
||||
// globalConfig.smoothing.smoothMainCanvas = getDeviceDPI() < 1.5;
|
||||
// globalConfig.smoothing.smoothMainCanvas = true;
|
||||
|
||||
canvas.classList.toggle("smoothed", globalConfig.smoothing.smoothMainCanvas);
|
||||
|
||||
// Oof, use :not() instead
|
||||
canvas.classList.toggle("unsmoothed", !globalConfig.smoothing.smoothMainCanvas);
|
||||
|
||||
if (globalConfig.smoothing.smoothMainCanvas) {
|
||||
enableImageSmoothing(context);
|
||||
} else {
|
||||
disableImageSmoothing(context);
|
||||
}
|
||||
|
||||
this.root.canvas = canvas;
|
||||
this.root.context = context;
|
||||
|
||||
registerCanvas(canvas, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructs the root, freeing all resources
|
||||
*/
|
||||
destruct() {
|
||||
if (lastCanvas && lastCanvas.parentElement) {
|
||||
lastCanvas.parentElement.removeChild(lastCanvas);
|
||||
}
|
||||
|
||||
this.root.destruct();
|
||||
delete this.root;
|
||||
this.root = null;
|
||||
this.app = null;
|
||||
}
|
||||
|
||||
tick(deltaMs) {
|
||||
const root = this.root;
|
||||
|
||||
if (root.hud.parts.processingOverlay.hasTasks() || root.hud.parts.processingOverlay.isRunning()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract current real time
|
||||
root.time.updateRealtimeNow();
|
||||
|
||||
// Camera is always updated, no matter what
|
||||
root.camera.update(deltaMs);
|
||||
|
||||
// Perform logic ticks
|
||||
this.root.time.performTicks(deltaMs, this.boundInternalTick);
|
||||
|
||||
// Update UI particles
|
||||
this.uiTimeBudget += deltaMs;
|
||||
const maxUiSteps = 3;
|
||||
if (this.uiTimeBudget > globalConfig.physicsDeltaMs * maxUiSteps) {
|
||||
this.uiTimeBudget = globalConfig.physicsDeltaMs;
|
||||
}
|
||||
while (this.uiTimeBudget >= globalConfig.physicsDeltaMs) {
|
||||
this.uiTimeBudget -= globalConfig.physicsDeltaMs;
|
||||
// root.uiParticleMgr.update();
|
||||
}
|
||||
|
||||
// Update automatic save after everything finished
|
||||
root.automaticSave.update();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRender() {
|
||||
if (this.root.queue.requireRedraw) {
|
||||
return true;
|
||||
}
|
||||
if (this.root.hud.shouldPauseRendering()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do not render
|
||||
if (!this.app.isRenderable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
updateLogic() {
|
||||
const root = this.root;
|
||||
this.duringLogicUpdate = true;
|
||||
|
||||
// Update entities, this removes destroyed entities
|
||||
root.entityMgr.update();
|
||||
|
||||
// IMPORTANT: At this point, the game might be game over. Stop if this is the case
|
||||
if (!this.root) {
|
||||
logger.log("Root destructed, returning false");
|
||||
return false;
|
||||
}
|
||||
|
||||
root.systemMgr.update();
|
||||
// root.particleMgr.update();
|
||||
|
||||
this.duringLogicUpdate = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
resize(w, h) {
|
||||
this.root.gameWidth = w;
|
||||
this.root.gameHeight = h;
|
||||
resizeHighDPICanvas(this.root.canvas, w, h, globalConfig.smoothing.smoothMainCanvas);
|
||||
this.root.signals.resized.dispatch(w, h);
|
||||
this.root.queue.requireRedraw = true;
|
||||
}
|
||||
|
||||
postLoadHook() {
|
||||
logger.log("Dispatching post load hook");
|
||||
this.root.signals.postLoadHook.dispatch();
|
||||
|
||||
if (!this.root.gameIsFresh) {
|
||||
// Also dispatch game restored hook on restored savegames
|
||||
this.root.signals.gameRestored.dispatch();
|
||||
}
|
||||
|
||||
this.root.gameInitialized = true;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const root = this.root;
|
||||
const systems = root.systemMgr.systems;
|
||||
|
||||
const taskRunner = root.hud.parts.processingOverlay;
|
||||
if (taskRunner.hasTasks()) {
|
||||
if (!taskRunner.isRunning()) {
|
||||
taskRunner.process();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.shouldRender()) {
|
||||
// Always update hud tho
|
||||
root.hud.update();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update buffers as the very first
|
||||
root.buffers.update();
|
||||
|
||||
root.queue.requireRedraw = false;
|
||||
|
||||
// Gather context and save all state
|
||||
const context = root.context;
|
||||
context.save();
|
||||
|
||||
// Compute optimal zoom level and atlas scale
|
||||
const zoomLevel = root.camera.zoomLevel;
|
||||
const effectiveZoomLevel =
|
||||
(zoomLevel / globalConfig.assetsDpi) * getDeviceDPI() * globalConfig.assetsSharpness;
|
||||
|
||||
let desiredAtlasScale = "0.1";
|
||||
if (effectiveZoomLevel > 0.75) {
|
||||
desiredAtlasScale = "1";
|
||||
} else if (effectiveZoomLevel > 0.5) {
|
||||
desiredAtlasScale = "0.75";
|
||||
} else if (effectiveZoomLevel > 0.25) {
|
||||
desiredAtlasScale = "0.5";
|
||||
} else if (effectiveZoomLevel > 0.1) {
|
||||
desiredAtlasScale = "0.25";
|
||||
}
|
||||
|
||||
// Construct parameters required for drawing
|
||||
const params = new DrawParameters({
|
||||
context: context,
|
||||
visibleRect: root.camera.getVisibleRect(),
|
||||
desiredAtlasScale,
|
||||
zoomLevel,
|
||||
root: root,
|
||||
});
|
||||
|
||||
if (G_IS_DEV && (globalConfig.debug.testCulling || globalConfig.debug.hideFog)) {
|
||||
context.clearRect(0, 0, root.gameWidth, root.gameHeight);
|
||||
}
|
||||
|
||||
// Transform to world space
|
||||
root.camera.transform(context);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame start");
|
||||
|
||||
// Update hud
|
||||
root.hud.update();
|
||||
|
||||
// Main rendering order
|
||||
// -----
|
||||
|
||||
root.map.drawBackground(params);
|
||||
// systems.mapResources.draw(params);
|
||||
|
||||
if (!this.root.camera.getIsMapOverlayActive()) {
|
||||
systems.itemProcessor.drawUnderlays(params);
|
||||
systems.belt.draw(params);
|
||||
systems.itemEjector.draw(params);
|
||||
systems.itemProcessor.draw(params);
|
||||
}
|
||||
|
||||
root.map.drawForeground(params);
|
||||
if (!this.root.camera.getIsMapOverlayActive()) {
|
||||
systems.hub.draw(params);
|
||||
}
|
||||
|
||||
if (G_IS_DEV) {
|
||||
root.map.drawStaticEntities(params);
|
||||
}
|
||||
|
||||
// END OF GAME CONTENT
|
||||
// -----
|
||||
|
||||
// Finally, draw the hud. Nothing should come after that
|
||||
root.hud.draw(params);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame end before restore");
|
||||
|
||||
// Restore to screen space
|
||||
context.restore();
|
||||
|
||||
// Draw overlays, those are screen space
|
||||
root.hud.drawOverlays(params);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "context.globalAlpha not 1 on frame end");
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.simulateSlowRendering) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 1e8; ++i) {
|
||||
sum += i;
|
||||
}
|
||||
if (Math_random() > 0.95) {
|
||||
console.log(sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
src/js/game/entity.js
Normal file
222
src/js/game/entity.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { Component } from "./component";
|
||||
/* typehints:end */
|
||||
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Vector, enumDirectionToVector, enumDirectionToAngle } from "../core/vector";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { EntityComponentStorage } from "./entity_components";
|
||||
import { Loader } from "../core/loader";
|
||||
import { drawRotatedSprite } from "../core/draw_utils";
|
||||
import { Math_radians } from "../core/builtins";
|
||||
// import { gFactionRegistry, gComponentRegistry } from "../core/global_registries";
|
||||
// import { EntityComponentStorage } from "./entity_components";
|
||||
|
||||
export class Entity extends BasicSerializableObject {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* Handle to the global game root
|
||||
*/
|
||||
this.root = root;
|
||||
|
||||
/**
|
||||
* The metaclass of the entity, should be set by subclasses
|
||||
*/
|
||||
this.meta = null;
|
||||
|
||||
/**
|
||||
* The components of the entity
|
||||
*/
|
||||
this.components = new EntityComponentStorage();
|
||||
|
||||
/**
|
||||
* Whether this entity was registered on the @see EntityManager so far
|
||||
*/
|
||||
this.registered = false;
|
||||
|
||||
/**
|
||||
* Internal entity unique id, set by the @see EntityManager
|
||||
*/
|
||||
this.uid = 0;
|
||||
|
||||
/* typehints:start */
|
||||
|
||||
/**
|
||||
* Stores if this entity is destroyed, set by the @see EntityManager
|
||||
* @type {boolean} */
|
||||
this.destroyed;
|
||||
|
||||
/**
|
||||
* Stores if this entity is queued to get destroyed in the next tick
|
||||
* of the @see EntityManager
|
||||
* @type {boolean} */
|
||||
this.queuedForDestroy;
|
||||
|
||||
/**
|
||||
* Stores the reason why this entity was destroyed
|
||||
* @type {string} */
|
||||
this.destroyReason;
|
||||
|
||||
/* typehints:end */
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "Entity";
|
||||
}
|
||||
|
||||
/**
|
||||
* @see BasicSerializableObject.getSchema
|
||||
* @returns {import("../savegame/serialization").Schema}
|
||||
*/
|
||||
static getSchema() {
|
||||
return {
|
||||
uid: types.uint,
|
||||
// components: types.keyValueMap(types.objData(gComponentRegistry), false)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the entity is still alive
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAlive() {
|
||||
return !this.destroyed && !this.queuedForDestroy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the meta class of the entity.
|
||||
* @returns {object}
|
||||
*/
|
||||
getMetaclass() {
|
||||
assert(this.meta, "Entity has no metaclass");
|
||||
return this.meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal destroy callback
|
||||
*/
|
||||
internalDestroyCallback() {
|
||||
assert(!this.destroyed, "Can not destroy entity twice");
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new component, only possible until the entity is registered on the entity manager,
|
||||
* after that use @see EntityManager.addDynamicComponent
|
||||
* @param {Component} componentInstance
|
||||
* @param {boolean} force Used by the entity manager. Internal parameter, do not change
|
||||
*/
|
||||
addComponent(componentInstance, force = false) {
|
||||
assert(force || !this.registered, "Entity already registered, use EntityManager.addDynamicComponent");
|
||||
const id = /** @type {typeof Component} */ (componentInstance.constructor).getId();
|
||||
assert(!this.components[id], "Component already present");
|
||||
this.components[id] = componentInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a given component, only possible until the entity is registered on the entity manager,
|
||||
* after that use @see EntityManager.removeDynamicComponent
|
||||
* @param {typeof Component} componentClass
|
||||
*/
|
||||
removeComponent(componentClass) {
|
||||
assert(!this.registered, "Entity already registered, use EntityManager.removeDynamicComponent");
|
||||
const id = componentClass.getId();
|
||||
assert(this.components[id], "Component does not exist on entity");
|
||||
delete this.components[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the entity, to override use @see Entity.drawImpl
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {
|
||||
const context = parameters.context;
|
||||
const staticComp = this.components.StaticMapEntity;
|
||||
|
||||
if (G_IS_DEV && staticComp && globalConfig.debug.showEntityBounds) {
|
||||
if (staticComp) {
|
||||
const transformed = staticComp.getTileSpaceBounds();
|
||||
context.strokeStyle = "rgba(255, 0, 0, 0.5)";
|
||||
context.lineWidth = 2;
|
||||
// const boundsSize = 20;
|
||||
context.beginPath();
|
||||
context.rect(
|
||||
transformed.x * globalConfig.tileSize,
|
||||
transformed.y * globalConfig.tileSize,
|
||||
transformed.w * globalConfig.tileSize,
|
||||
transformed.h * globalConfig.tileSize
|
||||
);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
if (G_IS_DEV && staticComp && globalConfig.debug.showAcceptorEjectors) {
|
||||
const ejectorComp = this.components.ItemEjector;
|
||||
|
||||
if (ejectorComp) {
|
||||
const ejectorSprite = Loader.getSprite("sprites/debug/ejector_slot.png");
|
||||
for (let i = 0; i < ejectorComp.slots.length; ++i) {
|
||||
const slot = ejectorComp.slots[i];
|
||||
const slotTile = staticComp.localTileToWorld(slot.pos);
|
||||
const direction = staticComp.localDirectionToWorld(slot.direction);
|
||||
const directionVector = enumDirectionToVector[direction];
|
||||
const angle = Math_radians(enumDirectionToAngle[direction]);
|
||||
|
||||
context.globalAlpha = slot.item ? 1 : 0.2;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite: ejectorSprite,
|
||||
x: (slotTile.x + 0.5 + directionVector.x * 0.37) * globalConfig.tileSize,
|
||||
y: (slotTile.y + 0.5 + directionVector.y * 0.37) * globalConfig.tileSize,
|
||||
angle,
|
||||
size: globalConfig.tileSize * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
const acceptorComp = this.components.ItemAcceptor;
|
||||
|
||||
if (acceptorComp) {
|
||||
const acceptorSprite = Loader.getSprite("sprites/debug/acceptor_slot.png");
|
||||
for (let i = 0; i < acceptorComp.slots.length; ++i) {
|
||||
const slot = acceptorComp.slots[i];
|
||||
const slotTile = staticComp.localTileToWorld(slot.pos);
|
||||
for (let k = 0; k < slot.directions.length; ++k) {
|
||||
const direction = staticComp.localDirectionToWorld(slot.directions[k]);
|
||||
const directionVector = enumDirectionToVector[direction];
|
||||
const angle = Math_radians(enumDirectionToAngle[direction] + 180);
|
||||
context.globalAlpha = 0.4;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite: acceptorSprite,
|
||||
x: (slotTile.x + 0.5 + directionVector.x * 0.37) * globalConfig.tileSize,
|
||||
y: (slotTile.y + 0.5 + directionVector.y * 0.37) * globalConfig.tileSize,
|
||||
angle,
|
||||
size: globalConfig.tileSize * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
// this.drawImpl(parameters);
|
||||
}
|
||||
|
||||
///// Helper interfaces
|
||||
|
||||
///// Interface to override by subclasses
|
||||
|
||||
/**
|
||||
* override, should draw the entity
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawImpl(parameters) {
|
||||
abstract;
|
||||
}
|
||||
}
|
||||
57
src/js/game/entity_components.js
Normal file
57
src/js/game/entity_components.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/* typehints:start */
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
import { BeltComponent } from "./components/belt";
|
||||
import { ItemEjectorComponent } from "./components/item_ejector";
|
||||
import { ItemAcceptorComponent } from "./components/item_acceptor";
|
||||
import { MinerComponent } from "./components/miner";
|
||||
import { ItemProcessorComponent } from "./components/item_processor";
|
||||
import { ReplaceableMapEntityComponent } from "./components/replaceable_map_entity";
|
||||
import { UndergroundBeltComponent } from "./components/underground_belt";
|
||||
import { UnremovableComponent } from "./components/unremovable";
|
||||
import { HubComponent } from "./components/hub";
|
||||
/* typehints:end */
|
||||
|
||||
/**
|
||||
* Typedefs for all entity components. These are not actually present on the entity,
|
||||
* thus they are undefined by default
|
||||
*/
|
||||
export class EntityComponentStorage {
|
||||
constructor() {
|
||||
// TODO: Figure out if its faster to declare all components here and not
|
||||
// compile them out (In theory, should make it a fast object in V8 engine)
|
||||
|
||||
/* typehints:start */
|
||||
|
||||
/** @type {StaticMapEntityComponent} */
|
||||
this.StaticMapEntity;
|
||||
|
||||
/** @type {BeltComponent} */
|
||||
this.Belt;
|
||||
|
||||
/** @type {ItemEjectorComponent} */
|
||||
this.ItemEjector;
|
||||
|
||||
/** @type {ItemAcceptorComponent} */
|
||||
this.ItemAcceptor;
|
||||
|
||||
/** @type {MinerComponent} */
|
||||
this.Miner;
|
||||
|
||||
/** @type {ItemProcessorComponent} */
|
||||
this.ItemProcessor;
|
||||
|
||||
/** @type {ReplaceableMapEntityComponent} */
|
||||
this.ReplaceableMapEntity;
|
||||
|
||||
/** @type {UndergroundBeltComponent} */
|
||||
this.UndergroundBelt;
|
||||
|
||||
/** @type {UnremovableComponent} */
|
||||
this.Unremovable;
|
||||
|
||||
/** @type {HubComponent} */
|
||||
this.Hub;
|
||||
|
||||
/* typehints:end */
|
||||
}
|
||||
}
|
||||
239
src/js/game/entity_manager.js
Normal file
239
src/js/game/entity_manager.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import { arrayDeleteValue, newEmptyMap } from "../core/utils";
|
||||
import { Component } from "./component";
|
||||
import { GameRoot } from "./root";
|
||||
import { Entity } from "./entity";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("entity_manager");
|
||||
|
||||
// Manages all entities
|
||||
|
||||
// TODO & NOTICE: We use arrayDeleteValue instead of fastArrayDeleteValue since that does not preserve the order
|
||||
// This is slower but we need it for the street path generation
|
||||
|
||||
export class EntityManager extends BasicSerializableObject {
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
/** @type {Array<Entity>} */
|
||||
this.entities = [];
|
||||
|
||||
// We store a seperate list with entities to destroy, since we don't destroy
|
||||
// them instantly
|
||||
/** @type {Array<Entity>} */
|
||||
this.destroyList = [];
|
||||
|
||||
// Store a map from componentid to entities - This is used by the game system
|
||||
// for faster processing
|
||||
/** @type {Object.<string, Array<Entity>>} */
|
||||
this.componentToEntity = newEmptyMap();
|
||||
|
||||
// Store the next uid to use
|
||||
this.nextUid = 10000;
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "EntityManager";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
nextUid: types.uint,
|
||||
};
|
||||
}
|
||||
|
||||
getStatsText() {
|
||||
return this.entities.length + " entities [" + this.destroyList.length + " to kill]";
|
||||
}
|
||||
|
||||
// Main update
|
||||
update() {
|
||||
this.processDestroyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new entity
|
||||
* @param {Entity} entity
|
||||
* @param {number=} uid Optional predefined uid
|
||||
*/
|
||||
registerEntity(entity, uid = null) {
|
||||
assert(this.entities.indexOf(entity) < 0, `RegisterEntity() called twice for entity ${entity}`);
|
||||
assert(!entity.destroyed, `Attempting to register destroyed entity ${entity}`);
|
||||
|
||||
if (G_IS_DEV && uid !== null) {
|
||||
assert(!this.findByUid(uid, false), "Entity uid already taken: " + uid);
|
||||
}
|
||||
|
||||
if (uid !== null) {
|
||||
assert(uid >= 0 && uid < Number.MAX_SAFE_INTEGER, "Invalid uid passed: " + uid);
|
||||
}
|
||||
|
||||
this.entities.push(entity);
|
||||
|
||||
// Register into the componentToEntity map
|
||||
for (const componentId in entity.components) {
|
||||
if (entity.components[componentId]) {
|
||||
if (this.componentToEntity[componentId]) {
|
||||
this.componentToEntity[componentId].push(entity);
|
||||
} else {
|
||||
this.componentToEntity[componentId] = [entity];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Give each entity a unique id
|
||||
entity.uid = uid ? uid : this.generateUid();
|
||||
entity.registered = true;
|
||||
|
||||
this.root.signals.entityAdded.dispatch(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts all entitiy lists after a resync
|
||||
*/
|
||||
sortEntityLists() {
|
||||
this.entities.sort((a, b) => a.uid - b.uid);
|
||||
this.destroyList.sort((a, b) => a.uid - b.uid);
|
||||
|
||||
for (const key in this.componentToEntity) {
|
||||
this.componentToEntity[key].sort((a, b) => a.uid - b.uid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new uid
|
||||
* @returns {number}
|
||||
*/
|
||||
generateUid() {
|
||||
return this.nextUid++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call to attach a new component after the creation of the entity
|
||||
* @param {Entity} entity
|
||||
* @param {Component} component
|
||||
*/
|
||||
attachDynamicComponent(entity, component) {
|
||||
entity.addComponent(component, true);
|
||||
const componentId = /** @type {typeof Component} */ (component.constructor).getId();
|
||||
if (this.componentToEntity[componentId]) {
|
||||
this.componentToEntity[componentId].push(entity);
|
||||
} else {
|
||||
this.componentToEntity[componentId] = [entity];
|
||||
}
|
||||
this.root.signals.entityGotNewComponent.dispatch(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an entity buy its uid, kinda slow since it loops over all entities
|
||||
* @param {number} uid
|
||||
* @param {boolean=} errorWhenNotFound
|
||||
* @returns {Entity}
|
||||
*/
|
||||
findByUid(uid, errorWhenNotFound = true) {
|
||||
const arr = this.entities;
|
||||
for (let i = 0, len = arr.length; i < len; ++i) {
|
||||
const entity = arr[i];
|
||||
if (entity.uid === uid) {
|
||||
if (entity.queuedForDestroy || entity.destroyed) {
|
||||
if (errorWhenNotFound) {
|
||||
logger.warn("Entity with UID", uid, "not found (destroyed)");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
if (errorWhenNotFound) {
|
||||
logger.warn("Entity with UID", uid, "not found");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entities having the given component
|
||||
* @param {typeof Component} componentHandle
|
||||
* @returns {Array<Entity>} entities
|
||||
*/
|
||||
getAllWithComponent(componentHandle) {
|
||||
return this.componentToEntity[componentHandle.getId()] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all of a given class. This is SLOW!
|
||||
* @param {object} entityClass
|
||||
* @returns {Array<Entity>} entities
|
||||
*/
|
||||
getAllOfClass(entityClass) {
|
||||
// FIXME: Slow
|
||||
const result = [];
|
||||
for (let i = 0; i < this.entities.length; ++i) {
|
||||
const entity = this.entities[i];
|
||||
if (entity instanceof entityClass) {
|
||||
result.push(entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters all components of an entity from the component to entity mapping
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
unregisterEntityComponents(entity) {
|
||||
for (const componentId in entity.components) {
|
||||
if (entity.components[componentId]) {
|
||||
arrayDeleteValue(this.componentToEntity[componentId], entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Processes the entities to destroy and actually destroys them
|
||||
/* eslint-disable max-statements */
|
||||
processDestroyList() {
|
||||
for (let i = 0; i < this.destroyList.length; ++i) {
|
||||
const entity = this.destroyList[i];
|
||||
|
||||
// Remove from entities list
|
||||
arrayDeleteValue(this.entities, entity);
|
||||
|
||||
// Remove from componentToEntity list
|
||||
this.unregisterEntityComponents(entity);
|
||||
|
||||
entity.registered = false;
|
||||
entity.internalDestroyCallback();
|
||||
|
||||
this.root.signals.entityDestroyed.dispatch(entity);
|
||||
}
|
||||
|
||||
this.destroyList = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues an entity for destruction
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
destroyEntity(entity) {
|
||||
if (entity.destroyed) {
|
||||
logger.error("Tried to destroy already destroyed entity:", entity.uid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.queuedForDestroy) {
|
||||
logger.error("Trying to destroy entity which is already queued for destroy!", entity.uid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.destroyList.indexOf(entity) < 0) {
|
||||
this.destroyList.push(entity);
|
||||
entity.queuedForDestroy = true;
|
||||
this.root.signals.entityQueuedForDestroy.dispatch(entity);
|
||||
} else {
|
||||
assert(false, "Trying to destroy entity twice");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user