ветки и кусты

This commit is contained in:
azykov@mail.ru
2026-09-04 17:18:31 +03:00
parent e872867c5e
commit 03a7552b9a
12 changed files with 482 additions and 224 deletions
+74 -30
View File
@@ -2,41 +2,57 @@ import { observer } from 'mobx-react-lite';
import { useEffect, useRef, useState } from 'react';
import type { PointerEvent } from 'react';
import type { GameStore } from '../game/GameStore';
import type { LogDrop, TreeState } from '../game/types';
import { CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE } from '../game/sprites';
import type { ResourceDrop, ResourceNode } from '../game/types';
import { BUSH_SPRITE, CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE, TWIG_SPRITE } from '../game/sprites';
import PixelSprite from './PixelSprite';
interface Props {
store: GameStore;
}
// 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)';
// Each entity below is its own observer so mobx can subscribe it only to the fields it actually
// reads. Otherwise, with everything read inside GameMap's single observer, the character moving
// every animation frame would re-render the whole tree/log list too, not just itself.
// every animation frame would re-render the whole node/drop list too, not just itself.
const LogEntity = observer(function LogEntity({ log }: { log: LogDrop }) {
const DROP_SPRITES = { log: LOG_SPRITE, twig: TWIG_SPRITE };
const DROP_SIZES = { log: 20, twig: 18 };
const ResourceDropEntity = observer(function ResourceDropEntity({ drop }: { drop: ResourceDrop }) {
return (
<div>
<div className="entity log-shadow" style={{ left: log.x, top: log.y }} />
<div className="entity log" style={{ left: log.x, top: log.y - log.z, zIndex: Math.floor(log.y - log.z) }}>
<PixelSprite pixels={LOG_SPRITE} size={20} />
<div className={`entity ${drop.resource}-shadow`} style={{ left: drop.x, top: drop.y }} />
<div
className={`entity ${drop.resource}`}
style={{ left: drop.x, top: drop.y - drop.z, zIndex: Math.floor(drop.y - drop.z) }}
>
<PixelSprite pixels={DROP_SPRITES[drop.resource]} size={DROP_SIZES[drop.resource]} />
</div>
</div>
);
});
const TreeEntity = observer(function TreeEntity({ tree, store }: { tree: TreeState; store: GameStore }) {
const isChopping = tree.id === store.heldTreeId && store.isChopping;
const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
const NODE_SIZES = { tree: 64, bush: 40 };
const NodeEntity = observer(function NodeEntity({ node, store }: { node: ResourceNode; store: GameStore }) {
const isChopping = node.id === store.heldNodeId && store.isChopping;
return (
<div>
<div className="entity tree-shadow" style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) - 1 }} />
<div className={`entity ${node.kind}-shadow`} style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) - 1 }} />
<div
className={`entity tree ${isChopping ? 'tree--chopping' : ''}`}
style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) }}
data-tree-id={tree.id}
className={`entity ${node.kind} ${isChopping ? 'chopping' : ''}`}
style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) }}
data-node-id={node.id}
>
<PixelSprite pixels={TREE_SPRITE} size={64} />
<PixelSprite pixels={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
</div>
</div>
);
@@ -62,13 +78,27 @@ const CharacterEntity = observer(function CharacterEntity({ store }: { store: Ga
function GameMap({ store }: 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;
const { width, height } = container.getBoundingClientRect();
// 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();
@@ -90,31 +120,45 @@ function GameMap({ store }: Props) {
};
}, [store]);
/** Finds the tree (if any) the pointer is currently over, by DOM hit-testing at its client
* coordinates. */
function treeIdAtPoint(clientX: number, clientY: number): string | null {
const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-tree-id]');
return hit?.getAttribute('data-tree-id') ?? null;
/** Finds the node (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 nodeIdAtPoint(clientX: number, clientY: number): string | null {
const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-node-id]');
return hit?.getAttribute('data-node-id') ?? 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 tree to chop, or open ground to walk to. Shared by pointerdown/move/up so panning the
* cursor across a tree mid-drag starts/stops chopping it, and releasing the button stops it. */
* 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 treeId = treeIdAtPoint(e.clientX, e.clientY);
if (treeId) {
store.holdTree(treeId);
const nodeId = nodeIdAtPoint(e.clientX, e.clientY);
if (nodeId) {
store.holdNode(nodeId);
return;
}
const rect = e.currentTarget.getBoundingClientRect();
store.moveTo(e.clientX - rect.left, e.clientY - rect.top);
const { x, y } = toLocalPoint(e, rect);
store.moveTo(x, y);
}
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
@@ -133,12 +177,12 @@ function GameMap({ store }: Props) {
>
{ready && (
<>
{store.logDrops.map((log) => (
<LogEntity key={log.id} log={log} />
{store.resourceDrops.map((drop) => (
<ResourceDropEntity key={drop.id} drop={drop} />
))}
{store.trees.map((tree) => (
<TreeEntity key={tree.id} tree={tree} store={store} />
{store.nodes.map((node) => (
<NodeEntity key={node.id} node={node} store={store} />
))}
<CharacterEntity store={store} />