Torre de Babel hecha de un millón de galletas. Cómo creamos el juego en la mini aplicación VK

¡Oye! Mi nombre es Sergey, estoy a cargo del desarrollo del front-end para proyectos especiales en KTS . Los proyectos especiales son pequeñas aplicaciones publicitarias, a menudo con una mecánica bastante compleja. En la primavera de 2020, junto con el equipo de proyectos especiales de VKontakte, se nos ocurrió el concepto y la mecánica del juego para el quinto aniversario de Oreo en Rusia.





, , - 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