цикл день-ночь, кролики, кровавая луна

This commit is contained in:
azykov@mail.ru
2026-09-04 20:31:53 +03:00
parent 982bbce813
commit 396a604671
17 changed files with 1061 additions and 171 deletions
+57 -74
View File
@@ -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>