initial commit
This commit is contained in:
+217
@@ -0,0 +1,217 @@
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px 16px;
|
||||
background: var(--panel-bg);
|
||||
border-bottom: 4px solid var(--panel-border);
|
||||
box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar__title {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
text-shadow: 2px 2px 0 var(--panel-border);
|
||||
}
|
||||
|
||||
.topbar .spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.inventory-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 3px solid var(--panel-border);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
background: var(--panel-bg);
|
||||
border-right: 4px solid var(--panel-border);
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
text-shadow: 2px 2px 0 var(--panel-border);
|
||||
}
|
||||
|
||||
.upgrade-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upgrade-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 3px solid var(--panel-border);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upgrade-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.upgrade-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.upgrade-btn__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upgrade-btn__level {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.upgrade-btn__effects {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #cbd5c0;
|
||||
}
|
||||
|
||||
.upgrade-btn__arrow {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.upgrade-btn__cost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
color: #f5c518;
|
||||
}
|
||||
|
||||
.game-map {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
background-color: #4caf50;
|
||||
background-image:
|
||||
linear-gradient(45deg, #46a047 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #46a047 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #46a047 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #46a047 75%);
|
||||
background-size: 32px 32px;
|
||||
background-position: 0 0, 0 16px, 16px -16px, -16px 0px;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.entity {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.entity.log-shadow {
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
margin: 8px 0px 0px 0px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.tree-shadow {
|
||||
width: 64px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
margin: 26px 0px 0px -11px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.character-shadow {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin: 32px 0px 0px 0px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.tree {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
margin: -12px;
|
||||
}
|
||||
|
||||
.entity.tree.tree--chopping svg {
|
||||
animation: chop-shake 0.12s infinite;
|
||||
}
|
||||
|
||||
.entity.character {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.entity.character.character--walking svg {
|
||||
animation: walk-bob 0.3s infinite steps(2);
|
||||
}
|
||||
|
||||
@keyframes chop-shake {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(-2px, 1px) rotate(-6deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(1px, -2px) rotate(5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-1px, 1px) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes walk-bob {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { useState } from 'react';
|
||||
import GameMap from './components/GameMap';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Topbar from './components/Topbar';
|
||||
import { GameStore } from './game/GameStore';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [store] = useState(() => new GameStore());
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Topbar store={store} />
|
||||
<div className="main-row">
|
||||
<Sidebar store={store} />
|
||||
<GameMap store={store} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { memo } from 'react';
|
||||
import type { PixelGrid } from '../game/sprites';
|
||||
|
||||
interface Props {
|
||||
pixels: PixelGrid;
|
||||
size: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PixelSprite({ pixels, size, className }: Props) {
|
||||
const rows = pixels.length;
|
||||
const cols = pixels[0]?.length ?? 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={cols > 0 ? size * (rows / cols) : size}
|
||||
viewBox={`0 0 ${cols} ${rows}`}
|
||||
shapeRendering="crispEdges"
|
||||
>
|
||||
{pixels.flatMap((row, y) =>
|
||||
row.map((color, x) =>
|
||||
color ? <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={color} /> : null,
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// `pixels` is always one of the module-level sprite constants and `size` is a literal passed by
|
||||
// the caller, so both are reference/value-stable across re-renders — memoizing means an entity
|
||||
// re-rendering every frame for its position (left/top) doesn't also regenerate this sprite's
|
||||
// (potentially hundreds of) <rect> elements each time.
|
||||
export default memo(PixelSprite);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { costForNextLevel, UPGRADE_LIST } from '../game/upgrades';
|
||||
import { COIN_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
}
|
||||
|
||||
function Sidebar({ store }: Props) {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<h2 className="sidebar__title">Апгрейды</h2>
|
||||
<div className="upgrade-list">
|
||||
{UPGRADE_LIST.map((def) => {
|
||||
const level = store.upgradeLevels[def.id];
|
||||
const cost = costForNextLevel(def, level);
|
||||
const canAfford = store.inventory.coins >= cost;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={def.id}
|
||||
type="button"
|
||||
className="upgrade-btn"
|
||||
disabled={!canAfford}
|
||||
onClick={() => store.buyUpgrade(def.id)}
|
||||
>
|
||||
<div className="upgrade-btn__header">
|
||||
<span className="upgrade-btn__name">{def.name}</span>
|
||||
<span className="upgrade-btn__level">Ур. {level}</span>
|
||||
</div>
|
||||
<div className="upgrade-btn__effects">
|
||||
<span>{def.formatEffect(def.effectAtLevel(level))}</span>
|
||||
<span className="upgrade-btn__arrow">></span>
|
||||
<span>{def.formatEffect(def.effectAtLevel(level + 1))}</span>
|
||||
</div>
|
||||
<div className="upgrade-btn__cost">
|
||||
<PixelSprite pixels={COIN_SPRITE} size={16} />
|
||||
<span>{cost}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Sidebar);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { COIN_SPRITE, LOG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
}
|
||||
|
||||
function Topbar({ store }: Props) {
|
||||
return (
|
||||
<div className="topbar">
|
||||
<h1 className="topbar__title">Forest</h1>
|
||||
<div className="spacer" />
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={LOG_SPRITE} size={28} />
|
||||
<span>{store.inventory.logs}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={COIN_SPRITE} size={24} />
|
||||
<span>{store.inventory.coins}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Topbar);
|
||||
@@ -0,0 +1,405 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
|
||||
import {
|
||||
INTERACTION_RADIUS,
|
||||
LOG_COLLECT_ARC_HEIGHT,
|
||||
LOG_COLLECT_DURATION,
|
||||
LOG_EJECT_RADIUS,
|
||||
LOG_FALL_ARC_HEIGHT,
|
||||
LOG_FALL_DURATION,
|
||||
LOGS_PER_TREE,
|
||||
TREE_COUNT,
|
||||
TREE_EDGE_MARGIN,
|
||||
TREE_MIN_DISTANCE_BETWEEN,
|
||||
TREE_MIN_DISTANCE_FROM_CENTER,
|
||||
} from './constants';
|
||||
import { loadSave, writeSave } from './persistence';
|
||||
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
||||
|
||||
/** How often (seconds of played time) the game autosaves while running. */
|
||||
const AUTOSAVE_INTERVAL = 2;
|
||||
|
||||
function randomId(prefix: string): string {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export class GameStore {
|
||||
character: Vec2 = { x: 0, y: 0 };
|
||||
trees: TreeState[] = [];
|
||||
logDrops: LogDrop[] = [];
|
||||
inventory: Inventory = { logs: 0, coins: 0 };
|
||||
heldTreeId: string | null = null;
|
||||
/** Free-form walk destination set by pressing on empty map, mutually exclusive with heldTreeId. */
|
||||
moveTarget: Vec2 | null = null;
|
||||
upgradeLevels: Record<UpgradeId, number> = {
|
||||
moveSpeed: 1,
|
||||
chopSpeed: 1,
|
||||
logValue: 1,
|
||||
pickupRadius: 1,
|
||||
coinSpeed: 1,
|
||||
};
|
||||
|
||||
private chopProgress = 0;
|
||||
private coinProgress = 0;
|
||||
private saveProgress = 0;
|
||||
private lastTimestamp: number | null = null;
|
||||
private rafId: number | null = null;
|
||||
|
||||
constructor() {
|
||||
// autoBind so methods stay correctly bound when passed as bare references (event handlers,
|
||||
// e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`).
|
||||
// Deliberately no side effects (e.g. global event listener registration) here beyond
|
||||
// makeAutoObservable: React StrictMode double-invokes `useState(() => new GameStore())`'s
|
||||
// initializer in dev, constructing (and discarding) an extra instance — any listener
|
||||
// registered in the constructor would leak and fire for that discarded instance too. Global
|
||||
// listeners like beforeunload belong in a useEffect instead (see GameMap), which StrictMode
|
||||
// handles correctly via its mount/cleanup/remount cycle.
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
get heldTree(): TreeState | undefined {
|
||||
return this.trees.find((tree) => tree.id === this.heldTreeId);
|
||||
}
|
||||
|
||||
/** Whether the character is currently in chopping range of the held tree (as opposed to still walking to it). */
|
||||
get isChopping(): boolean {
|
||||
const tree = this.heldTree;
|
||||
if (!tree)
|
||||
return false;
|
||||
return Math.hypot(tree.x - this.character.x, tree.y - this.character.y) <= INTERACTION_RADIUS;
|
||||
}
|
||||
|
||||
/** Whether the character is actively walking (toward a held tree or a free-form move target). */
|
||||
get isMoving(): boolean {
|
||||
if (this.heldTreeId)
|
||||
return !this.isChopping;
|
||||
if (this.moveTarget)
|
||||
return Math.hypot(this.moveTarget.x - this.character.x, this.moveTarget.y - this.character.y) > 0.5;
|
||||
return false;
|
||||
}
|
||||
|
||||
get characterSpeed(): number {
|
||||
return UPGRADES.moveSpeed.effectAtLevel(this.upgradeLevels.moveSpeed);
|
||||
}
|
||||
|
||||
get chopInterval(): number {
|
||||
const logsPerSecond = UPGRADES.chopSpeed.effectAtLevel(this.upgradeLevels.chopSpeed);
|
||||
return 1 / logsPerSecond;
|
||||
}
|
||||
|
||||
get coinsPerLog(): number {
|
||||
return UPGRADES.logValue.effectAtLevel(this.upgradeLevels.logValue);
|
||||
}
|
||||
|
||||
get pickupRadiusPx(): number {
|
||||
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
|
||||
}
|
||||
|
||||
get coinConversionInterval(): number {
|
||||
const logsPerSecond = UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
|
||||
return 1 / logsPerSecond;
|
||||
}
|
||||
|
||||
/** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */
|
||||
buyUpgrade(id: UpgradeId): boolean {
|
||||
const level = this.upgradeLevels[id];
|
||||
const cost = costForNextLevel(UPGRADES[id], level);
|
||||
if (this.inventory.coins < cost)
|
||||
return false;
|
||||
|
||||
this.inventory.coins -= cost;
|
||||
this.upgradeLevels[id] = level + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** (Re)initializes the game for a map of the given size. Restores a saved game (character/trees/
|
||||
* logs/progress) if one exists; otherwise places the character in the center and scatters
|
||||
* fresh trees. */
|
||||
initialize(width: number, height: number) {
|
||||
this.heldTreeId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
this.coinProgress = 0;
|
||||
this.saveProgress = 0;
|
||||
|
||||
const save = loadSave();
|
||||
if (save?.character && save.trees && save.logDrops) {
|
||||
this.character = { ...save.character };
|
||||
this.trees = save.trees.map((tree) => ({ ...tree }));
|
||||
this.logDrops = save.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined }));
|
||||
} else {
|
||||
this.character = { x: width / 2, y: height / 2 };
|
||||
this.trees = this.spawnTrees(width, height);
|
||||
this.logDrops = [];
|
||||
}
|
||||
|
||||
this.inventory = save ? { ...save.inventory } : { logs: 0, coins: 0 };
|
||||
if (save)
|
||||
this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels };
|
||||
}
|
||||
|
||||
/** Persists the full game (world state + progress) to localStorage right away. */
|
||||
save() {
|
||||
writeSave({
|
||||
version: 1,
|
||||
inventory: { ...this.inventory },
|
||||
upgradeLevels: { ...this.upgradeLevels },
|
||||
character: { ...this.character },
|
||||
trees: this.trees.map((tree) => ({ ...tree })),
|
||||
logDrops: this.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined })),
|
||||
});
|
||||
}
|
||||
|
||||
private spawnTrees(width: number, height: number): TreeState[] {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const trees: TreeState[] = [];
|
||||
let attempts = 0;
|
||||
|
||||
while (trees.length < TREE_COUNT && attempts < 1000) {
|
||||
attempts++;
|
||||
const x = TREE_EDGE_MARGIN + Math.random() * Math.max(0, width - TREE_EDGE_MARGIN * 2);
|
||||
const y = TREE_EDGE_MARGIN + Math.random() * Math.max(0, height - TREE_EDGE_MARGIN * 2);
|
||||
|
||||
if (Math.hypot(x - centerX, y - centerY) < TREE_MIN_DISTANCE_FROM_CENTER)
|
||||
continue;
|
||||
if (trees.some((tree) => Math.hypot(tree.x - x, tree.y - y) < TREE_MIN_DISTANCE_BETWEEN))
|
||||
continue;
|
||||
|
||||
trees.push({ id: randomId('tree'), x, y, logsRemaining: LOGS_PER_TREE });
|
||||
}
|
||||
|
||||
return trees;
|
||||
}
|
||||
|
||||
holdTree(id: string) {
|
||||
if (this.trees.some((tree) => tree.id === id)) {
|
||||
this.moveTarget = null;
|
||||
this.heldTreeId = id;
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts walking freely toward an arbitrary point on the map (e.g. a click on empty ground). */
|
||||
moveTo(x: number, y: number) {
|
||||
this.heldTreeId = null;
|
||||
this.chopProgress = 0;
|
||||
this.moveTarget = { x, y };
|
||||
}
|
||||
|
||||
release() {
|
||||
this.heldTreeId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.rafId !== null)
|
||||
return;
|
||||
this.lastTimestamp = null;
|
||||
|
||||
const loop = (timestamp: number) => {
|
||||
const last = this.lastTimestamp ?? timestamp;
|
||||
// Cap the per-frame step so a stalled/backgrounded tab doesn't cause a huge catch-up jump
|
||||
// (e.g. character teleporting across the map) once it resumes.
|
||||
const deltaTime = Math.min((timestamp - last) / 1000, 0.1);
|
||||
this.lastTimestamp = timestamp;
|
||||
this.tick(deltaTime);
|
||||
this.rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
this.rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this.lastTimestamp = null;
|
||||
}
|
||||
|
||||
private tick(deltaTime: number) {
|
||||
this.updateMovementAndChopping(deltaTime);
|
||||
this.updateLogFlights(deltaTime);
|
||||
this.updatePickups();
|
||||
this.updateCoinConversion(deltaTime);
|
||||
this.updateAutosave(deltaTime);
|
||||
}
|
||||
|
||||
private updateAutosave(deltaTime: number) {
|
||||
this.saveProgress += deltaTime;
|
||||
if (this.saveProgress < AUTOSAVE_INTERVAL)
|
||||
return;
|
||||
this.saveProgress = 0;
|
||||
this.save();
|
||||
}
|
||||
|
||||
private updateMovementAndChopping(deltaTime: number) {
|
||||
const tree = this.heldTree;
|
||||
if (tree) {
|
||||
this.walkTowardTree(tree, deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.moveTarget) {
|
||||
this.walkTowardPoint(this.moveTarget, deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
this.chopProgress = 0;
|
||||
}
|
||||
|
||||
private walkTowardPoint(target: Vec2, deltaTime: number) {
|
||||
const dx = target.x - this.character.x;
|
||||
const dy = target.y - this.character.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance <= 0.5) {
|
||||
this.character = { x: target.x, y: target.y };
|
||||
return;
|
||||
}
|
||||
|
||||
const step = Math.min(this.characterSpeed * deltaTime, distance);
|
||||
const ratio = step / distance;
|
||||
this.character = {
|
||||
x: this.character.x + dx * ratio,
|
||||
y: this.character.y + dy * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
private walkTowardTree(tree: TreeState, deltaTime: number) {
|
||||
const dx = tree.x - this.character.x;
|
||||
const dy = tree.y - this.character.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
// A small tolerance keeps the character from flip-flopping between "walking" and "chopping"
|
||||
// every frame once it's essentially at the interaction radius, which floating-point error on
|
||||
// the two-axis step below can otherwise cause (each flip resets chopProgress, so chopping
|
||||
// could stall forever right at the boundary).
|
||||
if (distance > INTERACTION_RADIUS + 0.5) {
|
||||
const step = Math.min(this.characterSpeed * deltaTime, distance - INTERACTION_RADIUS);
|
||||
const ratio = step / distance;
|
||||
this.character = {
|
||||
x: this.character.x + dx * ratio,
|
||||
y: this.character.y + dy * ratio,
|
||||
};
|
||||
this.chopProgress = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const chopInterval = this.chopInterval;
|
||||
this.chopProgress += deltaTime;
|
||||
while (this.chopProgress >= chopInterval && tree.logsRemaining > 0) {
|
||||
this.chopProgress -= chopInterval;
|
||||
tree.logsRemaining -= 1;
|
||||
|
||||
// Eject the log on a ballistic arc to a random spot within a circle around the tree; it
|
||||
// only becomes pickable once it lands (see updateLogFlights/updatePickups).
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const landDistance = Math.random() * LOG_EJECT_RADIUS;
|
||||
const toX = tree.x + Math.cos(angle) * landDistance;
|
||||
const toY = tree.y + Math.sin(angle) * landDistance;
|
||||
this.logDrops.push({
|
||||
id: randomId('log'),
|
||||
x: tree.x,
|
||||
y: tree.y,
|
||||
z: 0,
|
||||
phase: 'falling',
|
||||
flight: {
|
||||
fromX: tree.x,
|
||||
fromY: tree.y,
|
||||
toX,
|
||||
toY,
|
||||
elapsed: 0,
|
||||
duration: LOG_FALL_DURATION,
|
||||
arcHeight: LOG_FALL_ARC_HEIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
if (tree.logsRemaining <= 0) {
|
||||
this.trees = this.trees.filter((t) => t.id !== tree.id);
|
||||
this.heldTreeId = null;
|
||||
this.chopProgress = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Advances every log currently in a ballistic flight (falling from a tree, or flying into the
|
||||
* inventory once collected), landing/collecting it once its flight completes. Mutates logs in
|
||||
* place and only reassigns the `logDrops` array when one is actually removed (collected) — a
|
||||
* landed log sitting idle, or the array being rebuilt every tick regardless, would otherwise
|
||||
* force every observer of `logDrops` (i.e. the whole map) to re-render every animation frame. */
|
||||
private updateLogFlights(deltaTime: number) {
|
||||
if (this.logDrops.length === 0)
|
||||
return;
|
||||
|
||||
let collectedIds: Set<string> | null = null;
|
||||
for (const log of this.logDrops) {
|
||||
if (log.phase === 'landed')
|
||||
continue;
|
||||
|
||||
const flight = log.flight!;
|
||||
flight.elapsed += deltaTime;
|
||||
const t = Math.min(1, flight.elapsed / flight.duration);
|
||||
log.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
||||
log.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
||||
log.z = flight.arcHeight * 4 * t * (1 - t);
|
||||
|
||||
if (t < 1)
|
||||
continue;
|
||||
|
||||
if (log.phase === 'falling') {
|
||||
log.phase = 'landed';
|
||||
log.flight = undefined;
|
||||
log.z = 0;
|
||||
} else {
|
||||
// Collecting flight finished: the log has reached the character.
|
||||
(collectedIds ??= new Set()).add(log.id);
|
||||
this.inventory.logs += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (collectedIds)
|
||||
this.logDrops = this.logDrops.filter((log) => !collectedIds!.has(log.id));
|
||||
}
|
||||
|
||||
/** Starts a landed log flying into the inventory once the character gets close enough. */
|
||||
private updatePickups() {
|
||||
const pickupRadius = this.pickupRadiusPx;
|
||||
for (const log of this.logDrops) {
|
||||
if (log.phase !== 'landed')
|
||||
continue;
|
||||
|
||||
const distance = Math.hypot(log.x - this.character.x, log.y - this.character.y);
|
||||
if (distance > pickupRadius)
|
||||
continue;
|
||||
|
||||
log.phase = 'collecting';
|
||||
log.flight = {
|
||||
fromX: log.x,
|
||||
fromY: log.y,
|
||||
toX: this.character.x,
|
||||
toY: this.character.y,
|
||||
elapsed: 0,
|
||||
duration: LOG_COLLECT_DURATION,
|
||||
arcHeight: LOG_COLLECT_ARC_HEIGHT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private updateCoinConversion(deltaTime: number) {
|
||||
if (this.inventory.logs <= 0) {
|
||||
this.coinProgress = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const coinsPerLog = this.coinsPerLog;
|
||||
const conversionInterval = this.coinConversionInterval;
|
||||
this.coinProgress += deltaTime;
|
||||
while (this.coinProgress >= conversionInterval && this.inventory.logs > 0) {
|
||||
this.coinProgress -= conversionInterval;
|
||||
this.inventory.logs -= 1;
|
||||
this.inventory.coins += coinsPerLog;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export const TREE_COUNT = 5;
|
||||
export const LOGS_PER_TREE = 5;
|
||||
|
||||
/** How close the character must get to a tree's center to start chopping it. */
|
||||
export const INTERACTION_RADIUS = 56;
|
||||
|
||||
/** Radius of the circle around a tree that ejected logs can land in. */
|
||||
export const LOG_EJECT_RADIUS = 70;
|
||||
|
||||
/** Seconds for a log's ballistic flight from the tree to its landing spot. */
|
||||
export const LOG_FALL_DURATION = 0.5;
|
||||
|
||||
/** Peak height (px) of a log's arc while falling from the tree. */
|
||||
export const LOG_FALL_ARC_HEIGHT = 40;
|
||||
|
||||
/** Seconds for a landed log's flight into the character's inventory. */
|
||||
export const LOG_COLLECT_DURATION = 0.25;
|
||||
|
||||
/** Peak height (px) of a log's little hop while flying into the inventory. */
|
||||
export const LOG_COLLECT_ARC_HEIGHT = 16;
|
||||
|
||||
/** Trees won't spawn closer than this to the character's starting position. */
|
||||
export const TREE_MIN_DISTANCE_FROM_CENTER = 140;
|
||||
|
||||
/** Trees won't spawn closer than this to each other. */
|
||||
export const TREE_MIN_DISTANCE_BETWEEN = 100;
|
||||
|
||||
/** Trees won't spawn within this many px of the map edge. */
|
||||
export const TREE_EDGE_MARGIN = 70;
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
|
||||
import type { UpgradeId } from './upgrades';
|
||||
|
||||
const STORAGE_KEY = 'forest-save-v1';
|
||||
|
||||
export interface SaveData {
|
||||
version: 1;
|
||||
inventory: Inventory;
|
||||
upgradeLevels: Record<UpgradeId, number>;
|
||||
/** World state (character/trees/logs) is optional so older saves without it still load fine —
|
||||
* progress is restored either way, just with a freshly spawned world. */
|
||||
character?: Vec2;
|
||||
trees?: TreeState[];
|
||||
logDrops?: LogDrop[];
|
||||
}
|
||||
|
||||
function isValidSave(value: unknown): value is SaveData {
|
||||
if (!value || typeof value !== 'object')
|
||||
return false;
|
||||
const data = value as Partial<SaveData>;
|
||||
return (
|
||||
data.version === 1
|
||||
&& typeof data.inventory === 'object' && data.inventory !== null
|
||||
&& typeof data.inventory.logs === 'number'
|
||||
&& typeof data.inventory.coins === 'number'
|
||||
&& typeof data.upgradeLevels === 'object' && data.upgradeLevels !== null
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads the saved game from localStorage, or null if there's none / it's corrupted. */
|
||||
export function loadSave(): SaveData | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw)
|
||||
return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isValidSave(parsed) ? parsed : null;
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, disabled) or corrupted JSON — treat as no save.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the save to localStorage. Best-effort: silently no-ops if storage is unavailable/full. */
|
||||
export function writeSave(data: SaveData): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// Ignore — e.g. private browsing mode or quota exceeded.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Pixel grids for 8-bit-style sprites. Each cell is a CSS color, or '' for transparent. */
|
||||
export type PixelGrid = string[][];
|
||||
|
||||
function grid(rows: string[], palette: Record<string, string>): PixelGrid {
|
||||
return rows.map((row) => [...row].map((ch) => palette[ch] ?? ''));
|
||||
}
|
||||
|
||||
const CHARACTER_PALETTE = {
|
||||
O: '#1b1611',
|
||||
H: '#4a2f1c',
|
||||
S: '#f0c090',
|
||||
s: '#d9a873',
|
||||
B: '#3f6fb0',
|
||||
b: '#2f5488',
|
||||
P: '#2c2c3e',
|
||||
p: '#20202e',
|
||||
F: '#2a1f14',
|
||||
};
|
||||
|
||||
export const CHARACTER_SPRITE: PixelGrid = grid(
|
||||
[
|
||||
'....OOOOOOOO....',
|
||||
'..OHHHHHHHHHHO..',
|
||||
'.OHHHHHHHHHHHHO.',
|
||||
'OHHHHHHHHHHHHHHO',
|
||||
'OHHSSSSSSSSSSHHO',
|
||||
'OSSSSOSSSSOSSSSO',
|
||||
'OSSSSSSssSSSSSSO',
|
||||
'.OSSSSSSSSSSSSO.',
|
||||
'......OSSO......',
|
||||
'...OBBBBBBBBO...',
|
||||
'..OBBBBBBBBBBO..',
|
||||
'.OSBBBBBBBBBBSO.',
|
||||
'.OSBBbBBBBbBBSO.',
|
||||
'.OSBBbBBBBbBBSO.',
|
||||
'..OBBbBBBBbBBO..',
|
||||
'..OPPpPPPPpPPO..',
|
||||
'..OPPpPPPPpPPO..',
|
||||
'..OPPpO..OpPPO..',
|
||||
'..OPPpO..OpPPO..',
|
||||
'..OPPpO..OpPPO..',
|
||||
'..OFFFO..OFFFO..',
|
||||
'.OFFFFO..OFFFFO.',
|
||||
],
|
||||
CHARACTER_PALETTE,
|
||||
);
|
||||
|
||||
const TREE_PALETTE = {
|
||||
O: '#14290f',
|
||||
G: '#2e7d32',
|
||||
g: '#1b5e20',
|
||||
H: '#66bb6a',
|
||||
T: '#6b4226',
|
||||
t: '#4e2f1a',
|
||||
n: '#8a5a34',
|
||||
};
|
||||
|
||||
export const TREE_SPRITE: PixelGrid = grid(
|
||||
[
|
||||
'......OggO......',
|
||||
'....OGGggGGO....',
|
||||
'..OGGGGggGGGGO..',
|
||||
'.OGgGGGHHGGGgGO.',
|
||||
'OGgGGGGHHGGGGgGO',
|
||||
'OGGgGGGggGGGgGGO',
|
||||
'OGgGGGgGGgGGGgGO',
|
||||
'OGGgGGGGGGGGgGGO',
|
||||
'OGgGGGGggGGGGgGO',
|
||||
'.OGgGGGggGGGgGO.',
|
||||
'..OGgGGggGGgGO..',
|
||||
'...OGgGggGgGO...',
|
||||
'....OGgGGgGO....',
|
||||
'......OggO......',
|
||||
'......OTTO......',
|
||||
'......TTTT......',
|
||||
'......TttT......',
|
||||
'......tnnt......',
|
||||
'......TTTT......',
|
||||
'......TOOT......',
|
||||
],
|
||||
TREE_PALETTE,
|
||||
);
|
||||
|
||||
const LOG_PALETTE = {
|
||||
O: '#2a1a0d',
|
||||
B: '#7a4a2b',
|
||||
C: '#a9703f',
|
||||
};
|
||||
|
||||
export const LOG_SPRITE: PixelGrid = grid(
|
||||
[
|
||||
'..OOOOOOOOOOOOOOOO..',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'..OOOOOOOOOOOOOOOO..',
|
||||
],
|
||||
LOG_PALETTE,
|
||||
);
|
||||
|
||||
const COIN_PALETTE = {
|
||||
Y: '#f5c518',
|
||||
D: '#c99a0e',
|
||||
W: '#fff2b0',
|
||||
E: '#a97c08',
|
||||
};
|
||||
|
||||
export const COIN_SPRITE: PixelGrid = grid(
|
||||
[
|
||||
'.....YYYYYY.....',
|
||||
'...YYYYYYYYYY...',
|
||||
'..YWWYYYYYYYYY..',
|
||||
'.YYWWYYYYYYYYYY.',
|
||||
'.YYYYYYYYYYYYYY.',
|
||||
'YYYYYDDDDDDYYYYY',
|
||||
'YYYYYDYYYYDYYYYY',
|
||||
'YYYYYDYYYYDYYYYY',
|
||||
'YYYYYDYYYYDYYYYY',
|
||||
'YYYYYDYYYYDYYYYY',
|
||||
'YYYYYDDDDDDYYYYY',
|
||||
'.YYYYYYYYYYYYYY.',
|
||||
'.YYYYYYYYYYEEYY.',
|
||||
'..YYYYYYYYYEEY..',
|
||||
'...YYYYYYYYYY...',
|
||||
'.....YYYYYY.....',
|
||||
],
|
||||
COIN_PALETTE,
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface Vec2 {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface TreeState {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
logsRemaining: number;
|
||||
}
|
||||
|
||||
export type LogPhase = 'falling' | 'landed' | 'collecting';
|
||||
|
||||
export interface LogFlight {
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
elapsed: number;
|
||||
duration: number;
|
||||
arcHeight: number;
|
||||
}
|
||||
|
||||
export interface LogDrop {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */
|
||||
z: number;
|
||||
phase: LogPhase;
|
||||
flight?: LogFlight;
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
logs: number;
|
||||
coins: number;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type UpgradeId = 'moveSpeed' | 'chopSpeed' | 'logValue' | 'pickupRadius' | 'coinSpeed';
|
||||
|
||||
export interface UpgradeDef {
|
||||
id: UpgradeId;
|
||||
name: string;
|
||||
baseCost: number;
|
||||
costGrowth: number;
|
||||
/** The upgrade's stat value at a given level (levels start at 1, unbounded above). */
|
||||
effectAtLevel: (level: number) => number;
|
||||
formatEffect: (value: number) => string;
|
||||
}
|
||||
|
||||
export const UPGRADE_LIST: UpgradeDef[] = [
|
||||
{
|
||||
id: 'moveSpeed',
|
||||
name: 'Скорость движения',
|
||||
baseCost: 5,
|
||||
costGrowth: 1.35,
|
||||
effectAtLevel: (level) => 160 + (level - 1) * 20,
|
||||
formatEffect: (v) => `${Math.round(v)} px/с`,
|
||||
},
|
||||
{
|
||||
id: 'chopSpeed',
|
||||
name: 'Скорость рубки',
|
||||
baseCost: 6,
|
||||
costGrowth: 1.35,
|
||||
effectAtLevel: (level) => (1 / 0.6) * Math.pow(1.15, level - 1),
|
||||
formatEffect: (v) => `${v.toFixed(2)} бревен/с`,
|
||||
},
|
||||
{
|
||||
id: 'logValue',
|
||||
name: 'Стоимость бревна',
|
||||
baseCost: 8,
|
||||
costGrowth: 1.4,
|
||||
effectAtLevel: (level) => level,
|
||||
formatEffect: (v) => `${v} монет/бревно`,
|
||||
},
|
||||
{
|
||||
id: 'pickupRadius',
|
||||
name: 'Радиус подбора',
|
||||
baseCost: 5,
|
||||
costGrowth: 1.3,
|
||||
effectAtLevel: (level) => 46 + (level - 1) * 6,
|
||||
formatEffect: (v) => `${Math.round(v)} px`,
|
||||
},
|
||||
{
|
||||
id: 'coinSpeed',
|
||||
name: 'Скорость обмена на монеты',
|
||||
baseCost: 7,
|
||||
costGrowth: 1.35,
|
||||
effectAtLevel: (level) => Math.pow(1.15, level - 1),
|
||||
formatEffect: (v) => `${v.toFixed(2)} бревен/с`,
|
||||
},
|
||||
];
|
||||
|
||||
export const UPGRADES: Record<UpgradeId, UpgradeDef> = Object.fromEntries(
|
||||
UPGRADE_LIST.map((def) => [def.id, def]),
|
||||
) as Record<UpgradeId, UpgradeDef>;
|
||||
|
||||
/** Cost (in coins) to go from `currentLevel` to `currentLevel + 1`. */
|
||||
export function costForNextLevel(def: UpgradeDef, currentLevel: number): number {
|
||||
return Math.ceil(def.baseCost * Math.pow(def.costGrowth, currentLevel - 1));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
:root {
|
||||
--panel-bg: #3a2a1e;
|
||||
--panel-border: #1b1611;
|
||||
--text: #f5e9d0;
|
||||
--accent: #f5c518;
|
||||
|
||||
font: 16px/1.4 'Press Start 2P', ui-monospace, Consolas, monospace;
|
||||
color: var(--text);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user