Turm von Babel aus einer Million Keksen. Wie wir das Spiel in der VK Mini App gemacht haben

Hallo! Mein Name ist Sergey, ich bin verantwortlich für die Entwicklung des Frontends für spezielle Projekte bei KTS . Spezielle Projekte sind kleine Werbeanwendungen, oft mit recht komplexer Mechanik. Im Frühjahr 2020 haben wir zusammen mit dem VKontakte-Team von Sonderprojekten das Konzept und die Mechanik des Spiels zum 5. Jahrestag von Oreo in Russland entwickelt.





, , - Oreo, . , “” - , . , , . , , . “”, “” , , .





2020 , , . . :





, , . , , . VK Mini App - -, .





:





  • ( 200 ).





  • .





  • :





:



















1.

, , .





2 MobX-: GameStore GameUI.





GameStore , , , (, ). GameStore . ( ) .





GameUI , .





, :





<Wrapper>
 <Info />
 <Background />
 <Tower />
 <MiniTower />
</Wrapper>
      
      



display: flex direction: column-reverse. css, . , - .



. z-index’ . 100 -10 10px . 100 , .





export const TRANSLATIONS: number[] = Array.from(new Array(100)).map(
 () => (Math.random() - 0.5) * 20
);

const translation = TRANSLATIONS[index % TRANSLATIONS.length];

      
      



. react-use-gesture. api , , , . useDrag, . .





const bind = useDrag(
 ({ last, direction: [, dirY], vxvy: [, vy] }) => {
   if (dirY === 1 && vy > 0.2 && last) {
     gameStore.click();
   }
 },
 {
   axis: 'y',
   filterTaps: true,
 }
);

      
      



click gameStore @observable swipes ( ), update gameUi. :





// GameStore

@action.bound
click(): void {
 this.swipes += this.swipePower; //     

 this.rootStore.wsConnect
   .sendMessage(WSSendEvent.swipe, { times: 1 })

 this.gameUI.update({ count: this.swipePower });
}

// GameUI

@action
update({ count }: { count: number }): void {
 this.uiInteraction = true;
 this.newDiffCount = count;

 setTimeout(() => {
   this.uiInteraction = false;
//       
 }, NEW_OREO_ANIMATION_TIME + 100);
}

      
      



uiInteraction , , newDiffCount , “” . , .





transition + transform. gameStore.swipes - gameUi.newDiffCount, “” :





<Oreo
       isNew={
         (i + gameUi.newDiffCount >= gameStore.swipes) && gameUi.uiInteraction
       }
 />

OreoWrapper = styled.div<{ isNew?: boolean }>`
 transition: all ${NEW_OREO_ANIMATION_TIME}ms linear, 
             opacity ${NEW_OREO_ANIMATION_TIME / 3}ms linear;
 opacity: 1;

 ${(props) =>
   props.isNew &&
   css`
     opacity: 0;
     transform: translate(-50%, -200px);
   `}
`;

      
      



! . .





2.

. . -, . -, - , , . -, (, “”) , .





:





, , . GameUi, observable uiPosition. , . . css - ( ) translate.





useDrag react-use-gesture, , - GameUi.





, , . min-max uiPosition.





@action
moveGame(deltaY: number): void {
 if (Math.abs(deltaY) > 0) {
   this.uiPosition = Math.min(
     Math.max(0, this.uiPosition + deltaY),
     Math.max(
       0,
       this.game.swipes * OREO_HEIGHT_PX -
         windowHeight / 3
     )
   );


   this.trackLastOreo =
     this.towerHeight - this.uiPosition < windowHeight;
 }
}

      
      



trackLastOreo , . , , - .





uiPosition @computed GameUi . . , - :





get miniTowerPosition(): number {
 if (this.towerHeight === 0) {
   return 0;
 }
 const uiPositionPercent = this.uiPosition / this.towerHeight;

 return uiPositionPercent * this.miniTowerHeight;
}

      
      



, , - , .





:





const [{ y }, set] = useSpring(() => ({
 y: gameUi.uiPosition,
}));

<Tower
 style={{
   transform: y.to(
     (v: number) => `translate3d(0, ${v}px, 0)`
   ),
 }}
>

      
      



react-spring , , , css . .





3.

, . , , . - , , .





- , , .





, React-: react-virtualized react-virtuoso.



, , , .



, N + , , .



, .





GameUI . - , . - + , , .





, . , . 100 80% :





// GameUi

export const OVERSCAN = 100;
export const OVERSCAN_THRESHOLD = OVERSCAN * 0.8;

@action.bound
_updateVisibleIndexesImmediately = (): void => {
 const minVisibleIndex = Math.max(
   0,
   Math.floor(this.uiPosition / OREO_HEIGHT_PX)
 );
 const maxVisibleIndex = Math.min(
   this.game.swipes,
   Math.floor(
     (this.uiPosition + this.rootStore.uiStore.windowHeight) / OREO_HEIGHT_PX
   )
 );

 const [cachedMin, cachedMax] = this.cachedVisibleIndexes;

 if (
   (minVisibleIndex >= 0 &&
     minVisibleIndex - OVERSCAN_THRESHOLD < cachedMin) ||
   (maxVisibleIndex <= this.game.swipes &&
     maxVisibleIndex + OVERSCAN_THRESHOLD > cachedMax)
 ) {
   this.cachedVisibleIndexes = [
     Math.max(0, minVisibleIndex - OVERSCAN),
     Math.min(maxVisibleIndex + OVERSCAN, this.game.swipes),
   ];
 }
};
      
      



(, ), .





:





const [minVisibleIndex, maxVisibleIndex] = gameUi.cachedVisibleIndexes;

const oreosBlock = useMemo(() => {
 const oreos = [];
 for (let i = minVisibleIndex; i < maxVisibleIndex; i += 1) {
   oreos.push(
     <Oreo
       fillingId={gameStore.getCookieFiling(i)}
       isNew={
         (i + gameUi.newDiffCount >= gameStore.swipes) && gameUi.uiInteraction
       }
       index={i}
       key={i}
     />
   );
 }
 return oreos;
}, [minVisibleIndex, maxVisibleIndex]);

      
      



“” . “ ” .





@computed
get invisibleHeight(): number {
 const [minIndex] = this.cachedVisibleIndexes;
 return minIndex * OREO_HEIGHT_PX;
}


<Tower
 style={{
   marginBottom: `${gameUi.invisibleHeight}px`,
   transform: y.to(
     (v: number) => `translate3d(0, ${v}px, 0)`
   ),
 }}
>

      
      



, , , , , , DOM.





! , … ?





4.

, … . , :





. , margin-bottom translate :



margin-bottom: 3.99978e+07px;

transform: translate3d(0px, 3.99998e+07px, 0px);



( , ?), : css-, , . , .



, . , , , , . .





GameUI @computed , . ( - 1-2 ), :





@computed
get totalUiTowerBlocks(): number {
 return Math.ceil(this.game.swipes / 1000000);
}
      
      



, , + 1. . baseHeight - ( ), basePosition - .





// TowerBlock

if (index === total) {
 return oreos; //  
}

return (
 <Tower
   style={{
     marginBottom: `${baseHeight / total + (index === 0 ? 50 : 0)}px`,
     transform: basePosition.to(
       (v: number) => `translate3d(0, ${v / total}px, 0)`
     ),
   }}
 >
   <TowerBlock
     oreos={oreos}
     total={total}
     index={index + 1}
     baseHeight={baseHeight}
     basePosition={basePosition}
   />
 </TowerWrapper>
);

      
      



marginBottom translate , , .





“” .





5.

-, :





  1. -





  2. - ,





  3. - , , , , .





, :





, , , , -. , .





, “” . , 500 , 2000, , , . .





, 3 -, . , , , , -, - , . , ( translate ).





“” , ( ). , . .





get currentScreenPx(): number {
 const currentPointerSwipes = this.uiPosition / OREO_HEIGHT_PX;

 const currentLevel = getCurrentLevelBySwipes(currentPointerSwipes);

 const passedSwipes = currentPointerSwipes - currentLevel.swipes;

 const startProgress = Math.min(
   1,
   passedSwipes / currentLevel.startSwipesRequired
 );
 const mainProgress = Math.max(
   0,
   (passedSwipes - currentLevel.startSwipesRequired) /
     currentLevel.mainSwipesRequired
 );

 return (
   startProgress * currentLevel.startScreenHeight +
   mainProgress * currentLevel.mainScreenHeight +
   currentLevel.startPositionPx
 );
}
      
      



, .





, N + 1 N . .





6.

, … . . , , - . , , , - .





, react-spring css . . . , css- :)





“” gameUi -.





, .





, MobX @computed . , , . , . “” x6 .





.





, , , , , . , , , DOM-, .





, :

















, , !








All Articles