Files
game-forest/src/components/GameMap.tsx
T

190 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);