ねえ!私の名前はセルゲイですが、私は、特別なプロジェクトのためのフロントエンドの開発を担当していKTS。特別なプロジェクトは小さな広告アプリケーションであり、多くの場合、かなり複雑なメカニズムが使用されます。2020年の春、特別プロジェクトのVKontakteチームと協力して、ロシアのオレオ5周年に向けたゲームのコンセプトと仕組みを考案しました。
, , - 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.
-, :
-
- ,
- , , , , .
, :
, , , , -. , .
, “” . , 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-, .
, :
, , !