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
);
});
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 (