Skip to content

Commit

Permalink
feat: optimize cells re-renders through subscription
Browse files Browse the repository at this point in the history
  • Loading branch information
Nagellan committed Dec 9, 2023
1 parent c2f67a0 commit 779dab1
Show file tree
Hide file tree
Showing 10 changed files with 162 additions and 34 deletions.
6 changes: 6 additions & 0 deletions src/components/ActionTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 2,23 @@ import React from 'react';
import type { ReactElement } from 'react';

import { useGame } from '../hooks/useGame';
import { useRerender } from '../hooks/useRerender';
import { useDebounce } from '../hooks/useDebounce';
import { GAME_EVENT } from '../constants/game';
import { Tooltip, TIMEOUT } from './Tooltip';
import { Cell } from './Cell';

const RERENDER = [GAME_EVENT.MOVE, GAME_EVENT.ACTION];

type Props = {
children: ReactElement;
};

export const ActionTooltip = ({ children }: Props) => {
const { cube } = useGame();

useRerender(RERENDER);

const cell = cube.fov.getCell(...cube.position);
// условие с hasItem нужно для того, чтобы дебаунс работал только на уход с клетки с предметом
const cubeCellId = useDebounce(cell.id, cell.hasItem ? 0 : TIMEOUT);
Expand Down
29 changes: 26 additions & 3 deletions src/components/Cell.tsx
Original file line number Diff line number Diff line change
@@ -1,10 1,14 @@
import React, { useMemo, useRef } from 'react';
import React, { useMemo, useCallback, useRef } from 'react';
import { CSSTransition } from 'react-transition-group';

import { useGame } from '../hooks/useGame';
import { useRerender } from '../hooks/useRerender';
import { GAME_EVENT } from '../constants/game';
import { ITEM } from '../constants/item';
import type { CellType } from '../types/cell';
import type { GameEvent } from '../types/game';
import type { Item } from '../types/item';
import type { Game } from '../entities/Game';

const TIMEOUT = 250;

Expand Down Expand Up @@ -46,8 50,27 @@ export const Cell = ({ id }: Props) => {

const { getCell, cube } = useGame();

const cell = useMemo(() => getCell(id), [id]);
const visible = cube.fov.includes(...cell.position);
const cell = useMemo(() => getCell(id), [getCell, id]);
const visible = cube.fov.includes(cell.position);

// ре-рендерим только клетки в области видимости Кубика и на 1 клетку дальше
const getShouldRerender = useCallback(
(event: GameEvent, game: Game) => {
const _cell = game.map.getCellById(id);

switch (event) {
case GAME_EVENT.ACTION:
return game.cube.hasPosition(_cell.position);
case GAME_EVENT.MOVE:
return game.cube.fov.includes(_cell.position, 1);
default:
return false;
}
},
[id],
);

useRerender(getShouldRerender);

return (
<div className={getClassName(cell.type, visible)}>
Expand Down
4 changes: 2 additions & 2 deletions src/components/Floater.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 62,7 @@ const FloaterContentWrapper = ({
}

return undefined;
}, [anchorTop, anchorLeft, anchorWidth, size?.height, size?.width, gap]);
}, [size, anchorTop, anchorLeft, anchorWidth, gap]);

return (
<div className="floater" style={style} ref={setRef}>
Expand Down Expand Up @@ -115,7 115,7 @@ export const Floater = ({ children, content, gap = 0 }: Props) => {
resizeObserver.current.disconnect();
}
},
[children.props.ref],
[children.props.ref, updatePosition],
);

useEffect(() => {
Expand Down
5 changes: 5 additions & 0 deletions src/components/Inventory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 2,13 @@ import React, { useRef } from 'react';
import { CSSTransition } from 'react-transition-group';

import type { Item } from '../types/item';
import { useRerender } from '../hooks/useRerender';
import { GAME_EVENT } from '../constants/game';
import { useGame } from '../hooks/useGame';
import { getItem } from './Cell';

const TIMEOUT = 150;
const RERENDER = [GAME_EVENT.ACTION];

type Items = [Item, number][];

Expand All @@ -14,6 17,8 @@ export const Inventory = () => {

const { cube } = useGame();

useRerender(RERENDER);

const items = Object.entries(cube.getInventory()) as Items;

return (
Expand Down
26 changes: 21 additions & 5 deletions src/components/Map.tsx
Original file line number Diff line number Diff line change
@@ -1,25 1,41 @@
import React from 'react';
import React, { memo } from 'react';

import { useGame } from '../hooks/useGame';
import { Cell } from './Cell';
import { useRerender } from '../hooks/useRerender';
import type { Position } from '../types/positioned';
import { GAME_EVENT } from '../constants/game';
import { Cell } from './Cell';

const RERENDER = [GAME_EVENT.MOVE];

const getStyle = ([x, y]: Position, size: number) => ({
'--cube-position-x': x,
'--cube-position-y': y,
'--map-size': size,
});

export const Map = () => {
const { map, cube } = useGame();
const MapCells = memo(function MapCells() {
const { map } = useGame();

return (
<div className="map" style={getStyle(cube.position, map.size)}>
<>
{map.data.map((row) =>
row.map((cell) => {
return <Cell key={cell.id} id={cell.id} />;
}),
)}
</>
);
});

export const Map = () => {
const { map, cube } = useGame();

useRerender(RERENDER);

return (
<div className="map" style={getStyle(cube.position, map.size)}>
<MapCells />
</div>
);
};
14 changes: 10 additions & 4 deletions src/entities/FOV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 29,7 @@ export class FOV extends Positioned {
* Получить клетку внутри ФОВа по координатам, если она там имеется
*/
getCell(x: Coordinate, y: Coordinate): Cell {
if (!this.includes(x, y)) {
if (!this.includes([x, y])) {
throw new Error(
`Нельзя получить клетку ФОВа, которая в нём не находится: (${x}, ${y})`,
);
Expand All @@ -41,14 41,20 @@ export class FOV extends Positioned {
/**
* Установить дальность поля зрения (радиус окружности)
*/
setRange(range: number) {
setRange(range: number): void {
this.range = range;
}

/**
* Узнать, находится ли переданная точка в поле зрения (включительно)
*/
includes(x: Coordinate, y: Coordinate) {
return getIsPointInsideCircle(x, y, this.x, this.y, this.range);
includes([x, y]: Position, rangeExpansion = 0): boolean {
return getIsPointInsideCircle(
x,
y,
this.x,
this.y,
this.range rangeExpansion,
);
}
}
7 changes: 7 additions & 0 deletions src/entities/Positioned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 32,11 @@ export abstract class Positioned {
this.x = x;
this.y = y;
}

/**
* Узнать, находится ли сущность на переданной позиции
*/
hasPosition([x, y]: Position): boolean {
return this.x === x && this.y === y;
}
}
30 changes: 10 additions & 20 deletions src/hooks/useGame.ts
Original file line number Diff line number Diff line change
@@ -1,39 1,29 @@
import { useContext, useState, useEffect } from 'react';
import { useContext, useCallback } from 'react';

import { GameContext } from '../contexts/game';
import type { GameEvent } from '../types/game';

/**
* Точка взаимодействия интерфейсов и игровым движком.
* Производит ре-рендер компонента по месту вызова на каждое игровое событие.
*/
export const useGame = () => {
const game = useContext(GameContext);

// Используется для ре-рендера компонента
const [, setEvents] = useState<GameEvent[]>([]);

useEffect(() => {
if (!game) return;

const unsubscribe = game.subscribeToEvents(({ event }) => {
setEvents((prevEvents) => [...prevEvents, event]);
});

return () => {
unsubscribe();
};
}, []);

if (game === null) {
throw new Error('Hook useGame has been used outside of GameProvider');
throw new Error(
'Хук useGame должен быть вызван в пределах компонента GameProvider',
);
}

const getCell = useCallback(
(id: string) => game.map.getCellById(id),
[game.map],
);

return {
game,
map: game.map,
cube: game.cube,
fears: game.fears,
getCell: (id: string) => game.map.getCellById(id),
getCell,
};
};
71 changes: 71 additions & 0 deletions src/hooks/useRerender.ts
Original file line number Diff line number Diff line change
@@ -0,0 1,71 @@
import { useState, useEffect } from 'react';

import type { GameEvent } from '../types/game';
import type { Game } from '../entities/Game';
import { useGame } from './useGame';

type RerenderCondition =
| boolean
| GameEvent[]
| ((event: GameEvent, game: Game) => boolean | GameEvent[]);

const getIsRerenderRestricted = (condition: RerenderCondition): boolean => {
if (typeof condition === 'boolean') {
return !condition;
}

if (Array.isArray(condition)) {
return condition.length === 0;
}

return false;
};

const getShouldRerender = (
game: Game,
condition: RerenderCondition,
event: GameEvent,
): boolean => {
if (getIsRerenderRestricted(condition)) {
return false;
}

const _condition =
typeof condition === 'function' ? condition(event, game) : condition;

if (typeof _condition === 'boolean') {
return _condition;
}

if (Array.isArray(_condition)) {
return _condition.includes(event);
}

return false;
};

/**
* Производит ре-рендер компонента по месту вызова относительно переданного условия.
*/
export const useRerender = (condition: RerenderCondition = false): void => {
const { game } = useGame();

// Используется для ре-рендера компонента
const [, setEventCount] = useState<number>(0);

useEffect(() => {
if (getIsRerenderRestricted(condition)) {
return;
}

const unsubscribe = game.subscribeToEvents(({ event }) => {
if (getShouldRerender(game, condition, event)) {
setEventCount((count) => count 1);
}
});

return () => {
unsubscribe();
};
}, [game, condition]);
};
4 changes: 4 additions & 0 deletions src/providers/game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 13,10 @@ const game = new Game();
export const GameProvider = ({ children }: Props) => {
useEffect(() => {
game.start();

return () => {
game.stop();
};
}, []);

return <GameContext.Provider value={game}>{children}</GameContext.Provider>;
Expand Down

0 comments on commit 779dab1

Please sign in to comment.