190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
import { observer } from 'mobx-react-lite';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import type { PointerEvent } from 'react';
|
||
import type { GameStore } from '../game/GameStore';
|
||
import { overlayColorForHour } from '../game/daynight';
|
||
import CharacterEntity from './CharacterEntity';
|
||
import NodeEntity from './NodeEntity';
|
||
import RabbitEntity from './RabbitEntity';
|
||
import ResourceDropEntity from './ResourceDropEntity';
|
||
|
||
interface Props {
|
||
store: GameStore;
|
||
onDeath: () => void;
|
||
}
|
||
|
||
// Must mirror the @media rule in index.css that rotates #root into landscape on a portrait
|
||
// touchscreen — when that rule is active, the browser paints everything rotated 90°, but
|
||
// PointerEvent.clientX/clientY (and Element.getBoundingClientRect()) keep reporting the
|
||
// physical, unrotated screen: physical x/y and physical width/height end up swapped relative to
|
||
// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used.
|
||
const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)';
|
||
|
||
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
|
||
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
|
||
});
|
||
|
||
const BloodMoonBanner = observer(function BloodMoonBanner({ store }: { store: GameStore }) {
|
||
if (!store.bloodMoonBannerVisible)
|
||
return null;
|
||
return <div className="blood-moon-banner">КРОВАВАЯ ЛУНА!</div>;
|
||
});
|
||
|
||
const GameOverOverlay = observer(function GameOverOverlay({
|
||
store,
|
||
onDeath,
|
||
}: {
|
||
store: GameStore;
|
||
onDeath: () => void;
|
||
}) {
|
||
if (!store.isDead)
|
||
return null;
|
||
|
||
return (
|
||
<div className="game-over-overlay">
|
||
<div className="game-over-overlay__message">Кролик поймал вас ночью...</div>
|
||
<button type="button" className="menu-btn" onClick={onDeath}>
|
||
В меню
|
||
</button>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
function GameMap({ store, onDeath }: Props) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const [ready, setReady] = useState(false);
|
||
const isRotatedRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
const mql = window.matchMedia(ROTATE_QUERY);
|
||
isRotatedRef.current = mql.matches;
|
||
const handleChange = (e: MediaQueryListEvent) => {
|
||
isRotatedRef.current = e.matches;
|
||
};
|
||
mql.addEventListener('change', handleChange);
|
||
return () => mql.removeEventListener('change', handleChange);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container)
|
||
return;
|
||
|
||
// getBoundingClientRect() reports the physical (rotated) box, whose width/height are swapped
|
||
// relative to the app's own logical layout — see ROTATE_QUERY above.
|
||
const rect = container.getBoundingClientRect();
|
||
const [width, height] = isRotatedRef.current ? [rect.height, rect.width] : [rect.width, rect.height];
|
||
if (width > 0 && height > 0) {
|
||
store.initialize(width, height);
|
||
store.start();
|
||
setReady(true);
|
||
}
|
||
|
||
return () => {
|
||
store.stop();
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
window.addEventListener('blur', store.release);
|
||
window.addEventListener('beforeunload', store.save);
|
||
return () => {
|
||
window.removeEventListener('blur', store.release);
|
||
window.removeEventListener('beforeunload', store.save);
|
||
};
|
||
}, [store]);
|
||
|
||
/** Finds the node or rabbit (if any) the pointer is currently over, by DOM hit-testing at its
|
||
* client coordinates. elementFromPoint uses physical screen coordinates and already accounts
|
||
* for any CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */
|
||
function targetAtPoint(clientX: number, clientY: number): { kind: 'node' | 'rabbit'; id: string } | null {
|
||
const el = document.elementFromPoint(clientX, clientY);
|
||
const nodeHit = el?.closest('[data-node-id]');
|
||
if (nodeHit)
|
||
return { kind: 'node', id: nodeHit.getAttribute('data-node-id')! };
|
||
const rabbitHit = el?.closest('[data-rabbit-id]');
|
||
if (rabbitHit)
|
||
return { kind: 'rabbit', id: rabbitHit.getAttribute('data-rabbit-id')! };
|
||
return null;
|
||
}
|
||
|
||
/** Converts a pointer event's physical screen position into the map's own logical (x, y) —
|
||
* i.e. what the game world uses for the character's target position. When the map is rotated
|
||
* (see ROTATE_QUERY), physical x/y are swapped and one is mirrored relative to logical x/y, so
|
||
* a plain subtraction of rect.left/rect.top would point the character the wrong way. */
|
||
function toLocalPoint(e: PointerEvent<HTMLDivElement>, rect: DOMRect): { x: number; y: number } {
|
||
const physicalX = e.clientX - rect.left;
|
||
const physicalY = e.clientY - rect.top;
|
||
if (isRotatedRef.current)
|
||
return { x: physicalY, y: rect.width - physicalX };
|
||
return { x: physicalX, y: physicalY };
|
||
}
|
||
|
||
/** The character's action is purely a function of the current pointer state: if the primary
|
||
* button isn't held, nothing happens; otherwise it heads for whatever's under the cursor right
|
||
* now — a node to chop, or open ground to walk to. Shared by pointerdown/move/up so panning the
|
||
* cursor across a node mid-drag starts/stops chopping it, and releasing the button stops it. */
|
||
function followPointer(e: PointerEvent<HTMLDivElement>) {
|
||
if ((e.buttons & 1) === 0) {
|
||
store.release();
|
||
return;
|
||
}
|
||
|
||
const target = targetAtPoint(e.clientX, e.clientY);
|
||
if (target) {
|
||
if (target.kind === 'node')
|
||
store.holdNode(target.id);
|
||
else
|
||
store.holdRabbit(target.id);
|
||
return;
|
||
}
|
||
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const { x, y } = toLocalPoint(e, rect);
|
||
store.moveTo(x, y);
|
||
}
|
||
|
||
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
followPointer(e);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="game-map"
|
||
ref={containerRef}
|
||
onPointerDown={handlePointerDown}
|
||
onPointerMove={followPointer}
|
||
onPointerUp={followPointer}
|
||
onPointerCancel={store.release}
|
||
>
|
||
{ready && (
|
||
<>
|
||
{store.resourceDrops.map((drop) => (
|
||
<ResourceDropEntity key={drop.id} drop={drop} />
|
||
))}
|
||
|
||
{store.nodes.map((node) => (
|
||
<NodeEntity key={node.id} node={node} store={store} />
|
||
))}
|
||
|
||
{store.rabbits.map((rabbit) => (
|
||
<RabbitEntity key={rabbit.id} rabbit={rabbit} store={store} />
|
||
))}
|
||
|
||
<CharacterEntity store={store} />
|
||
|
||
<DayNightOverlay store={store} />
|
||
|
||
<BloodMoonBanner store={store} />
|
||
|
||
<GameOverOverlay store={store} onDeath={onDeath} />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default observer(GameMap);
|