цикл день-ночь, кролики, кровавая луна
This commit is contained in:
+57
-74
@@ -2,12 +2,15 @@ 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 { ResourceDrop, ResourceNode } from '../game/types';
|
||||
import { BUSH_SPRITE, CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
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
|
||||
@@ -17,65 +20,37 @@ interface Props {
|
||||
// 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 node/drop list too, not just itself.
|
||||
const DayNightOverlay = observer(function DayNightOverlay({ store }: { store: GameStore }) {
|
||||
return <div className="day-night-overlay" style={{ background: overlayColorForHour(store.hour) }} />;
|
||||
});
|
||||
|
||||
const DROP_SPRITES = { log: LOG_SPRITE, twig: TWIG_SPRITE };
|
||||
const DROP_SIZES = { log: 20, twig: 18 };
|
||||
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;
|
||||
|
||||
const ResourceDropEntity = observer(function ResourceDropEntity({ drop }: { drop: ResourceDrop }) {
|
||||
return (
|
||||
<div>
|
||||
<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 className="game-over-overlay">
|
||||
<div className="game-over-overlay__message">Кролик поймал вас ночью...</div>
|
||||
<button type="button" className="menu-btn" onClick={onDeath}>
|
||||
В меню
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 ${node.kind}-shadow`} style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) - 1 }} />
|
||||
<div
|
||||
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={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="entity character-shadow"
|
||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
|
||||
/>
|
||||
<div
|
||||
className={`entity character ${store.isMoving ? 'character--walking' : ''}`}
|
||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
|
||||
>
|
||||
<PixelSprite pixels={CHARACTER_SPRITE} size={48} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function GameMap({ store }: Props) {
|
||||
function GameMap({ store, onDeath }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const isRotatedRef = useRef(false);
|
||||
@@ -120,12 +95,18 @@ function GameMap({ store }: Props) {
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
/** 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;
|
||||
/** 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) —
|
||||
@@ -150,9 +131,12 @@ function GameMap({ store }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeId = nodeIdAtPoint(e.clientX, e.clientY);
|
||||
if (nodeId) {
|
||||
store.holdNode(nodeId);
|
||||
const target = targetAtPoint(e.clientX, e.clientY);
|
||||
if (target) {
|
||||
if (target.kind === 'node')
|
||||
store.holdNode(target.id);
|
||||
else
|
||||
store.holdRabbit(target.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -185,18 +169,17 @@ function GameMap({ store }: Props) {
|
||||
<NodeEntity key={node.id} node={node} store={store} />
|
||||
))}
|
||||
|
||||
{store.rabbits.map((rabbit) => (
|
||||
<RabbitEntity key={rabbit.id} rabbit={rabbit} store={store} />
|
||||
))}
|
||||
|
||||
<CharacterEntity store={store} />
|
||||
|
||||
{store.canAdvanceLevel && (
|
||||
<button
|
||||
type="button"
|
||||
className="next-level-btn"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={store.nextLevel}
|
||||
>
|
||||
Перейти на следующий уровень
|
||||
</button>
|
||||
)}
|
||||
<DayNightOverlay store={store} />
|
||||
|
||||
<BloodMoonBanner store={store} />
|
||||
|
||||
<GameOverOverlay store={store} onDeath={onDeath} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user