initial commit

This commit is contained in:
azykov@mail.ru
2026-09-04 16:03:17 +03:00
commit a05d5f4547
31 changed files with 2842 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
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 PixelSprite from './PixelSprite';
interface Props {
store: GameStore;
}
// 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.
const LogEntity = observer(function LogEntity({ log }: { log: LogDrop }) {
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>
</div>
);
});
const TreeEntity = observer(function TreeEntity({
tree,
store,
onPointerDown,
onPointerUp,
}: {
tree: TreeState;
store: GameStore;
onPointerDown: (e: PointerEvent<HTMLDivElement>, id: string) => void;
onPointerUp: (e: PointerEvent<HTMLDivElement>) => void;
}) {
const isChopping = tree.id === store.heldTreeId && 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 tree ${isChopping ? 'tree--chopping' : ''}`}
style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) }}
onPointerDown={(e) => onPointerDown(e, tree.id)}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<PixelSprite pixels={TREE_SPRITE} size={64} />
</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) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container)
return;
const { width, height } = container.getBoundingClientRect();
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]);
function handleTreePointerDown(e: PointerEvent<HTMLDivElement>, id: string) {
if (e.button !== 0)
return;
// Stop this from also bubbling to the map's own pointer handlers below (which would
// otherwise interpret the same press as "walk to this empty ground point").
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
store.holdTree(id);
}
function handleTreePointerUp(e: PointerEvent<HTMLDivElement>) {
e.stopPropagation();
store.release();
}
function handleMapPointerDown(e: PointerEvent<HTMLDivElement>) {
if (e.button !== 0)
return;
const rect = e.currentTarget.getBoundingClientRect();
e.currentTarget.setPointerCapture(e.pointerId);
store.moveTo(e.clientX - rect.left, e.clientY - rect.top);
}
return (
<div
className="game-map"
ref={containerRef}
onPointerDown={handleMapPointerDown}
onPointerUp={store.release}
onPointerCancel={store.release}
>
{ready && (
<>
{store.logDrops.map((log) => (
<LogEntity key={log.id} log={log} />
))}
{store.trees.map((tree) => (
<TreeEntity
key={tree.id}
tree={tree}
store={store}
onPointerDown={handleTreePointerDown}
onPointerUp={handleTreePointerUp}
/>
))}
<CharacterEntity store={store} />
</>
)}
</div>
);
}
export default observer(GameMap);