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
; }); const BloodMoonBanner = observer(function BloodMoonBanner({ store }: { store: GameStore }) { if (!store.bloodMoonBannerVisible) return null; return
КРОВАВАЯ ЛУНА!
; }); const GameOverOverlay = observer(function GameOverOverlay({ store, onDeath, }: { store: GameStore; onDeath: () => void; }) { if (!store.isDead) return null; return (
Кролик поймал вас ночью...
); }); function GameMap({ store, onDeath }: Props) { const containerRef = useRef(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, 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) { 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) { e.currentTarget.setPointerCapture(e.pointerId); followPointer(e); } return (
{ready && ( <> {store.resourceDrops.map((drop) => ( ))} {store.nodes.map((node) => ( ))} {store.rabbits.map((rabbit) => ( ))} )}
); } export default observer(GameMap);