ветки и кусты
This commit is contained in:
+74
-30
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user