ветки и кусты
This commit is contained in:
+4
-1
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
<title>forest</title>
|
<title>forest</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
+26
-1
@@ -155,6 +155,15 @@
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity.twig-shadow {
|
||||||
|
width: 18px;
|
||||||
|
height: 9px;
|
||||||
|
margin: 7px 0px 0px 0px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.entity.tree-shadow {
|
.entity.tree-shadow {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -164,6 +173,15 @@
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity.bush-shadow {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 16px 0px 0px -6px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.entity.character-shadow {
|
.entity.character-shadow {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -180,7 +198,14 @@
|
|||||||
margin: -12px;
|
margin: -12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity.tree.tree--chopping svg {
|
.entity.bush {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
margin: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity.chopping svg {
|
||||||
animation: chop-shake 0.12s infinite;
|
animation: chop-shake 0.12s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-30
@@ -2,41 +2,57 @@ import { observer } from 'mobx-react-lite';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { PointerEvent } from 'react';
|
import type { PointerEvent } from 'react';
|
||||||
import type { GameStore } from '../game/GameStore';
|
import type { GameStore } from '../game/GameStore';
|
||||||
import type { LogDrop, TreeState } from '../game/types';
|
import type { ResourceDrop, ResourceNode } from '../game/types';
|
||||||
import { CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE } from '../game/sprites';
|
import { BUSH_SPRITE, CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||||
import PixelSprite from './PixelSprite';
|
import PixelSprite from './PixelSprite';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
store: GameStore;
|
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
|
// 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
|
// 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="entity log-shadow" style={{ left: log.x, top: log.y }} />
|
<div className={`entity ${drop.resource}-shadow`} style={{ left: drop.x, top: drop.y }} />
|
||||||
<div className="entity log" style={{ left: log.x, top: log.y - log.z, zIndex: Math.floor(log.y - log.z) }}>
|
<div
|
||||||
<PixelSprite pixels={LOG_SPRITE} size={20} />
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const TreeEntity = observer(function TreeEntity({ tree, store }: { tree: TreeState; store: GameStore }) {
|
const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
|
||||||
const isChopping = tree.id === store.heldTreeId && store.isChopping;
|
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 (
|
return (
|
||||||
<div>
|
<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
|
<div
|
||||||
className={`entity tree ${isChopping ? 'tree--chopping' : ''}`}
|
className={`entity ${node.kind} ${isChopping ? 'chopping' : ''}`}
|
||||||
style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) }}
|
style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) }}
|
||||||
data-tree-id={tree.id}
|
data-node-id={node.id}
|
||||||
>
|
>
|
||||||
<PixelSprite pixels={TREE_SPRITE} size={64} />
|
<PixelSprite pixels={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -62,13 +78,27 @@ const CharacterEntity = observer(function CharacterEntity({ store }: { store: Ga
|
|||||||
function GameMap({ store }: Props) {
|
function GameMap({ store }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container)
|
if (!container)
|
||||||
return;
|
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) {
|
if (width > 0 && height > 0) {
|
||||||
store.initialize(width, height);
|
store.initialize(width, height);
|
||||||
store.start();
|
store.start();
|
||||||
@@ -90,31 +120,45 @@ function GameMap({ store }: Props) {
|
|||||||
};
|
};
|
||||||
}, [store]);
|
}, [store]);
|
||||||
|
|
||||||
/** Finds the tree (if any) the pointer is currently over, by DOM hit-testing at its client
|
/** Finds the node (if any) the pointer is currently over, by DOM hit-testing at its client
|
||||||
* coordinates. */
|
* coordinates. elementFromPoint uses physical screen coordinates and already accounts for any
|
||||||
function treeIdAtPoint(clientX: number, clientY: number): string | null {
|
* CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */
|
||||||
const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-tree-id]');
|
function nodeIdAtPoint(clientX: number, clientY: number): string | null {
|
||||||
return hit?.getAttribute('data-tree-id') ?? 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
|
/** 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
|
* 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
|
* now — a node 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. */
|
* cursor across a node mid-drag starts/stops chopping it, and releasing the button stops it. */
|
||||||
function followPointer(e: PointerEvent<HTMLDivElement>) {
|
function followPointer(e: PointerEvent<HTMLDivElement>) {
|
||||||
if ((e.buttons & 1) === 0) {
|
if ((e.buttons & 1) === 0) {
|
||||||
store.release();
|
store.release();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const treeId = treeIdAtPoint(e.clientX, e.clientY);
|
const nodeId = nodeIdAtPoint(e.clientX, e.clientY);
|
||||||
if (treeId) {
|
if (nodeId) {
|
||||||
store.holdTree(treeId);
|
store.holdNode(nodeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
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>) {
|
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
|
||||||
@@ -133,12 +177,12 @@ function GameMap({ store }: Props) {
|
|||||||
>
|
>
|
||||||
{ready && (
|
{ready && (
|
||||||
<>
|
<>
|
||||||
{store.logDrops.map((log) => (
|
{store.resourceDrops.map((drop) => (
|
||||||
<LogEntity key={log.id} log={log} />
|
<ResourceDropEntity key={drop.id} drop={drop} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{store.trees.map((tree) => (
|
{store.nodes.map((node) => (
|
||||||
<TreeEntity key={tree.id} tree={tree} store={store} />
|
<NodeEntity key={node.id} node={node} store={store} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<CharacterEntity store={store} />
|
<CharacterEntity store={store} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import type { GameStore } from '../game/GameStore';
|
import type { GameStore } from '../game/GameStore';
|
||||||
import { COIN_SPRITE, LOG_SPRITE } from '../game/sprites';
|
import { COIN_SPRITE, LOG_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||||
import PixelSprite from './PixelSprite';
|
import PixelSprite from './PixelSprite';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,13 +15,17 @@ function Topbar({ store }: Props) {
|
|||||||
<span>Уровень {store.level}</span>
|
<span>Уровень {store.level}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
|
<div className="inventory-slot">
|
||||||
|
<PixelSprite pixels={TWIG_SPRITE} size={22} />
|
||||||
|
<span>{store.inventory.twigs}</span>
|
||||||
|
</div>
|
||||||
<div className="inventory-slot">
|
<div className="inventory-slot">
|
||||||
<PixelSprite pixels={LOG_SPRITE} size={28} />
|
<PixelSprite pixels={LOG_SPRITE} size={28} />
|
||||||
<span>{store.inventory.logs}</span>
|
<span>{store.inventory.logs}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inventory-slot">
|
<div className="inventory-slot">
|
||||||
<PixelSprite pixels={COIN_SPRITE} size={24} />
|
<PixelSprite pixels={COIN_SPRITE} size={24} />
|
||||||
<span>{store.inventory.coins}</span>
|
<span>{store.inventory.coins.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+197
-132
@@ -1,17 +1,20 @@
|
|||||||
import { makeAutoObservable } from 'mobx';
|
import { makeAutoObservable } from 'mobx';
|
||||||
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
|
import type { Inventory, ResourceDrop, ResourceNode, ResourceType, Vec2 } from './types';
|
||||||
import {
|
import {
|
||||||
|
bushesForLevel,
|
||||||
|
DROP_COLLECT_ARC_HEIGHT,
|
||||||
|
DROP_COLLECT_DURATION,
|
||||||
|
DROP_EJECT_RADIUS,
|
||||||
|
DROP_FALL_ARC_HEIGHT,
|
||||||
|
DROP_FALL_DURATION,
|
||||||
|
ENTITY_EDGE_MARGIN,
|
||||||
|
ENTITY_MIN_DISTANCE_BETWEEN,
|
||||||
|
ENTITY_MIN_DISTANCE_FROM_CENTER,
|
||||||
INTERACTION_RADIUS,
|
INTERACTION_RADIUS,
|
||||||
LOG_COLLECT_ARC_HEIGHT,
|
|
||||||
LOG_COLLECT_DURATION,
|
|
||||||
LOG_EJECT_RADIUS,
|
|
||||||
LOG_FALL_ARC_HEIGHT,
|
|
||||||
LOG_FALL_DURATION,
|
|
||||||
LOGS_PER_TREE,
|
LOGS_PER_TREE,
|
||||||
TREE_EDGE_MARGIN,
|
|
||||||
TREE_MIN_DISTANCE_BETWEEN,
|
|
||||||
TREE_MIN_DISTANCE_FROM_CENTER,
|
|
||||||
treesForLevel,
|
treesForLevel,
|
||||||
|
TWIGS_PER_BUSH,
|
||||||
|
TWIGS_PER_TREE,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { loadSave, writeSave } from './persistence';
|
import { loadSave, writeSave } from './persistence';
|
||||||
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
||||||
@@ -19,23 +22,36 @@ import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
|
|||||||
/** How often (seconds of played time) the game autosaves while running. */
|
/** How often (seconds of played time) the game autosaves while running. */
|
||||||
const AUTOSAVE_INTERVAL = 2;
|
const AUTOSAVE_INTERVAL = 2;
|
||||||
|
|
||||||
|
/** A node yields its logs before its twigs. */
|
||||||
|
const RESOURCE_ORDER: ResourceType[] = ['log', 'twig'];
|
||||||
|
|
||||||
function randomId(prefix: string): string {
|
function randomId(prefix: string): string {
|
||||||
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDepleted(node: ResourceNode): boolean {
|
||||||
|
return RESOURCE_ORDER.every((resource) => node.remaining[resource] <= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next resource a node should yield when chopped, or null once it's fully depleted. */
|
||||||
|
function nextResource(node: ResourceNode): ResourceType | null {
|
||||||
|
return RESOURCE_ORDER.find((resource) => node.remaining[resource] > 0) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export class GameStore {
|
export class GameStore {
|
||||||
level = 1;
|
level = 1;
|
||||||
character: Vec2 = { x: 0, y: 0 };
|
character: Vec2 = { x: 0, y: 0 };
|
||||||
trees: TreeState[] = [];
|
nodes: ResourceNode[] = [];
|
||||||
logDrops: LogDrop[] = [];
|
resourceDrops: ResourceDrop[] = [];
|
||||||
inventory: Inventory = { logs: 0, coins: 0 };
|
inventory: Inventory = { logs: 0, twigs: 0, coins: 0 };
|
||||||
heldTreeId: string | null = null;
|
heldNodeId: string | null = null;
|
||||||
/** Free-form walk destination set by pressing on empty map, mutually exclusive with heldTreeId. */
|
/** Free-form walk destination set by pressing on empty map, mutually exclusive with heldNodeId. */
|
||||||
moveTarget: Vec2 | null = null;
|
moveTarget: Vec2 | null = null;
|
||||||
upgradeLevels: Record<UpgradeId, number> = {
|
upgradeLevels: Record<UpgradeId, number> = {
|
||||||
moveSpeed: 1,
|
moveSpeed: 1,
|
||||||
chopSpeed: 1,
|
chopSpeed: 1,
|
||||||
logValue: 1,
|
logValue: 1,
|
||||||
|
twigValue: 1,
|
||||||
pickupRadius: 1,
|
pickupRadius: 1,
|
||||||
coinSpeed: 1,
|
coinSpeed: 1,
|
||||||
};
|
};
|
||||||
@@ -45,7 +61,7 @@ export class GameStore {
|
|||||||
private saveProgress = 0;
|
private saveProgress = 0;
|
||||||
private lastTimestamp: number | null = null;
|
private lastTimestamp: number | null = null;
|
||||||
private rafId: number | null = null;
|
private rafId: number | null = null;
|
||||||
/** Map dimensions from the last initialize(), reused to spawn each new level's trees. */
|
/** Map dimensions from the last initialize(), reused to spawn each new level's nodes. */
|
||||||
private mapSize: { width: number; height: number } | null = null;
|
private mapSize: { width: number; height: number } | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -60,21 +76,21 @@ export class GameStore {
|
|||||||
makeAutoObservable(this, {}, { autoBind: true });
|
makeAutoObservable(this, {}, { autoBind: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
get heldTree(): TreeState | undefined {
|
get heldNode(): ResourceNode | undefined {
|
||||||
return this.trees.find((tree) => tree.id === this.heldTreeId);
|
return this.nodes.find((node) => node.id === this.heldNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the character is currently in chopping range of the held tree (as opposed to still walking to it). */
|
/** Whether the character is currently in chopping range of the held node (as opposed to still walking to it). */
|
||||||
get isChopping(): boolean {
|
get isChopping(): boolean {
|
||||||
const tree = this.heldTree;
|
const node = this.heldNode;
|
||||||
if (!tree)
|
if (!node)
|
||||||
return false;
|
return false;
|
||||||
return Math.hypot(tree.x - this.character.x, tree.y - this.character.y) <= INTERACTION_RADIUS;
|
return Math.hypot(node.x - this.character.x, node.y - this.character.y) <= INTERACTION_RADIUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the character is actively walking (toward a held tree or a free-form move target). */
|
/** Whether the character is actively walking (toward a held node or a free-form move target). */
|
||||||
get isMoving(): boolean {
|
get isMoving(): boolean {
|
||||||
if (this.heldTreeId)
|
if (this.heldNodeId)
|
||||||
return !this.isChopping;
|
return !this.isChopping;
|
||||||
if (this.moveTarget)
|
if (this.moveTarget)
|
||||||
return Math.hypot(this.moveTarget.x - this.character.x, this.moveTarget.y - this.character.y) > 0.5;
|
return Math.hypot(this.moveTarget.x - this.character.x, this.moveTarget.y - this.character.y) > 0.5;
|
||||||
@@ -86,26 +102,30 @@ export class GameStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get chopInterval(): number {
|
get chopInterval(): number {
|
||||||
const logsPerSecond = UPGRADES.chopSpeed.effectAtLevel(this.upgradeLevels.chopSpeed);
|
const perSecond = UPGRADES.chopSpeed.effectAtLevel(this.upgradeLevels.chopSpeed);
|
||||||
return 1 / logsPerSecond;
|
return 1 / perSecond;
|
||||||
}
|
}
|
||||||
|
|
||||||
get coinsPerLog(): number {
|
get coinsPerLog(): number {
|
||||||
return UPGRADES.logValue.effectAtLevel(this.upgradeLevels.logValue);
|
return UPGRADES.logValue.effectAtLevel(this.upgradeLevels.logValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get coinsPerTwig(): number {
|
||||||
|
return UPGRADES.twigValue.effectAtLevel(this.upgradeLevels.twigValue);
|
||||||
|
}
|
||||||
|
|
||||||
get pickupRadiusPx(): number {
|
get pickupRadiusPx(): number {
|
||||||
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
|
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seconds to convert one unit of a resource into coins. */
|
||||||
get coinConversionInterval(): number {
|
get coinConversionInterval(): number {
|
||||||
const logsPerSecond = UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
|
return UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
|
||||||
return 1 / logsPerSecond;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether every tree on the current level has been chopped down and every log collected. */
|
/** Whether every node on the current level has been chopped down and every resource collected. */
|
||||||
get canAdvanceLevel(): boolean {
|
get canAdvanceLevel(): boolean {
|
||||||
return this.trees.length === 0 && this.logDrops.length === 0;
|
return this.nodes.length === 0 && this.resourceDrops.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */
|
/** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */
|
||||||
@@ -120,12 +140,12 @@ export class GameStore {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** (Re)initializes the game for a map of the given size. Restores a saved game (character/trees/
|
/** (Re)initializes the game for a map of the given size. Restores a saved game (character/nodes/
|
||||||
* logs/progress) if one exists; otherwise places the character in the center and scatters
|
* drops/progress) if one exists; otherwise places the character in the center and scatters a
|
||||||
* fresh trees. */
|
* fresh level's worth of trees and bushes. */
|
||||||
initialize(width: number, height: number) {
|
initialize(width: number, height: number) {
|
||||||
this.mapSize = { width, height };
|
this.mapSize = { width, height };
|
||||||
this.heldTreeId = null;
|
this.heldNodeId = null;
|
||||||
this.moveTarget = null;
|
this.moveTarget = null;
|
||||||
this.chopProgress = 0;
|
this.chopProgress = 0;
|
||||||
this.coinProgress = 0;
|
this.coinProgress = 0;
|
||||||
@@ -134,88 +154,113 @@ export class GameStore {
|
|||||||
const save = loadSave();
|
const save = loadSave();
|
||||||
this.level = save?.level ?? 1;
|
this.level = save?.level ?? 1;
|
||||||
|
|
||||||
if (save?.character && save.trees && save.logDrops) {
|
if (save?.character && save.nodes && save.resourceDrops) {
|
||||||
this.character = { ...save.character };
|
this.character = { ...save.character };
|
||||||
this.trees = save.trees.map((tree) => ({ ...tree }));
|
this.nodes = save.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } }));
|
||||||
this.logDrops = save.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined }));
|
this.resourceDrops = save.resourceDrops.map((drop) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined }));
|
||||||
} else {
|
} else {
|
||||||
this.character = { x: width / 2, y: height / 2 };
|
this.character = { x: width / 2, y: height / 2 };
|
||||||
this.trees = this.spawnTrees(width, height);
|
this.nodes = this.spawnNodes(width, height);
|
||||||
this.logDrops = [];
|
this.resourceDrops = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inventory = save ? { ...save.inventory } : { logs: 0, coins: 0 };
|
this.inventory = save ? { ...save.inventory } : { logs: 0, twigs: 0, coins: 0 };
|
||||||
if (save)
|
if (save)
|
||||||
this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels };
|
this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Advances to the next level once every tree is chopped and every log collected: respawns a
|
/** Advances to the next level once every node is chopped and every resource collected: respawns
|
||||||
* fresh batch of trees (level + 4 of them) and clears the ground of logs. */
|
* a fresh batch of trees and bushes and clears the ground of drops. */
|
||||||
nextLevel() {
|
nextLevel() {
|
||||||
if (!this.canAdvanceLevel || !this.mapSize)
|
if (!this.canAdvanceLevel || !this.mapSize)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.level += 1;
|
this.level += 1;
|
||||||
this.heldTreeId = null;
|
this.heldNodeId = null;
|
||||||
this.moveTarget = null;
|
this.moveTarget = null;
|
||||||
this.chopProgress = 0;
|
this.chopProgress = 0;
|
||||||
this.trees = this.spawnTrees(this.mapSize.width, this.mapSize.height);
|
this.nodes = this.spawnNodes(this.mapSize.width, this.mapSize.height);
|
||||||
this.logDrops = [];
|
this.resourceDrops = [];
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists the full game (world state + progress) to localStorage right away. */
|
/** Persists the full game (world state + progress) to localStorage right away. */
|
||||||
save() {
|
save() {
|
||||||
writeSave({
|
writeSave({
|
||||||
version: 1,
|
version: 2,
|
||||||
inventory: { ...this.inventory },
|
inventory: { ...this.inventory },
|
||||||
upgradeLevels: { ...this.upgradeLevels },
|
upgradeLevels: { ...this.upgradeLevels },
|
||||||
character: { ...this.character },
|
character: { ...this.character },
|
||||||
trees: this.trees.map((tree) => ({ ...tree })),
|
nodes: this.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } })),
|
||||||
logDrops: this.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined })),
|
resourceDrops: this.resourceDrops.map((drop) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined })),
|
||||||
level: this.level,
|
level: this.level,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private spawnTrees(width: number, height: number): TreeState[] {
|
/** Scatters this level's trees and bushes, keeping every node clear of the character's starting
|
||||||
|
* spot, the map edge, and each other regardless of kind. */
|
||||||
|
private spawnNodes(width: number, height: number): ResourceNode[] {
|
||||||
|
const treePositions = this.spawnPositions(treesForLevel(this.level), width, height, []);
|
||||||
|
const bushPositions = this.spawnPositions(bushesForLevel(this.level), width, height, treePositions);
|
||||||
|
|
||||||
|
const trees: ResourceNode[] = treePositions.map((pos) => ({
|
||||||
|
id: randomId('tree'),
|
||||||
|
kind: 'tree',
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
remaining: { log: LOGS_PER_TREE, twig: TWIGS_PER_TREE },
|
||||||
|
}));
|
||||||
|
const bushes: ResourceNode[] = bushPositions.map((pos) => ({
|
||||||
|
id: randomId('bush'),
|
||||||
|
kind: 'bush',
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
remaining: { log: 0, twig: TWIGS_PER_BUSH },
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...trees, ...bushes];
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnPositions(count: number, width: number, height: number, existing: Vec2[]): Vec2[] {
|
||||||
const centerX = width / 2;
|
const centerX = width / 2;
|
||||||
const centerY = height / 2;
|
const centerY = height / 2;
|
||||||
const trees: TreeState[] = [];
|
const positions: Vec2[] = [];
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const treeCount = treesForLevel(this.level);
|
|
||||||
|
|
||||||
while (trees.length < treeCount && attempts < 1000) {
|
while (positions.length < count && attempts < 1000) {
|
||||||
attempts++;
|
attempts++;
|
||||||
const x = TREE_EDGE_MARGIN + Math.random() * Math.max(0, width - TREE_EDGE_MARGIN * 2);
|
const x = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, width - ENTITY_EDGE_MARGIN * 2);
|
||||||
const y = TREE_EDGE_MARGIN + Math.random() * Math.max(0, height - TREE_EDGE_MARGIN * 2);
|
const y = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, height - ENTITY_EDGE_MARGIN * 2);
|
||||||
|
|
||||||
if (Math.hypot(x - centerX, y - centerY) < TREE_MIN_DISTANCE_FROM_CENTER)
|
if (Math.hypot(x - centerX, y - centerY) < ENTITY_MIN_DISTANCE_FROM_CENTER)
|
||||||
continue;
|
continue;
|
||||||
if (trees.some((tree) => Math.hypot(tree.x - x, tree.y - y) < TREE_MIN_DISTANCE_BETWEEN))
|
if (existing.some((p) => Math.hypot(p.x - x, p.y - y) < ENTITY_MIN_DISTANCE_BETWEEN))
|
||||||
|
continue;
|
||||||
|
if (positions.some((p) => Math.hypot(p.x - x, p.y - y) < ENTITY_MIN_DISTANCE_BETWEEN))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
trees.push({ id: randomId('tree'), x, y, logsRemaining: LOGS_PER_TREE });
|
positions.push({ x, y });
|
||||||
}
|
}
|
||||||
|
|
||||||
return trees;
|
return positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
holdTree(id: string) {
|
holdNode(id: string) {
|
||||||
if (this.trees.some((tree) => tree.id === id)) {
|
if (this.nodes.some((node) => node.id === id)) {
|
||||||
this.moveTarget = null;
|
this.moveTarget = null;
|
||||||
this.heldTreeId = id;
|
this.heldNodeId = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts walking freely toward an arbitrary point on the map (e.g. a click on empty ground). */
|
/** Starts walking freely toward an arbitrary point on the map (e.g. a click on empty ground). */
|
||||||
moveTo(x: number, y: number) {
|
moveTo(x: number, y: number) {
|
||||||
this.heldTreeId = null;
|
this.heldNodeId = null;
|
||||||
this.chopProgress = 0;
|
this.chopProgress = 0;
|
||||||
this.moveTarget = { x, y };
|
this.moveTarget = { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
release() {
|
release() {
|
||||||
this.heldTreeId = null;
|
this.heldNodeId = null;
|
||||||
this.moveTarget = null;
|
this.moveTarget = null;
|
||||||
this.chopProgress = 0;
|
this.chopProgress = 0;
|
||||||
}
|
}
|
||||||
@@ -247,7 +292,7 @@ export class GameStore {
|
|||||||
|
|
||||||
private tick(deltaTime: number) {
|
private tick(deltaTime: number) {
|
||||||
this.updateMovementAndChopping(deltaTime);
|
this.updateMovementAndChopping(deltaTime);
|
||||||
this.updateLogFlights(deltaTime);
|
this.updateDropFlights(deltaTime);
|
||||||
this.updatePickups();
|
this.updatePickups();
|
||||||
this.updateCoinConversion(deltaTime);
|
this.updateCoinConversion(deltaTime);
|
||||||
this.updateAutosave(deltaTime);
|
this.updateAutosave(deltaTime);
|
||||||
@@ -262,9 +307,9 @@ export class GameStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateMovementAndChopping(deltaTime: number) {
|
private updateMovementAndChopping(deltaTime: number) {
|
||||||
const tree = this.heldTree;
|
const node = this.heldNode;
|
||||||
if (tree) {
|
if (node) {
|
||||||
this.walkTowardTree(tree, deltaTime);
|
this.walkTowardNode(node, deltaTime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,9 +339,9 @@ export class GameStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private walkTowardTree(tree: TreeState, deltaTime: number) {
|
private walkTowardNode(node: ResourceNode, deltaTime: number) {
|
||||||
const dx = tree.x - this.character.x;
|
const dx = node.x - this.character.x;
|
||||||
const dy = tree.y - this.character.y;
|
const dy = node.y - this.character.y;
|
||||||
const distance = Math.hypot(dx, dy);
|
const distance = Math.hypot(dx, dy);
|
||||||
|
|
||||||
// A small tolerance keeps the character from flip-flopping between "walking" and "chopping"
|
// A small tolerance keeps the character from flip-flopping between "walking" and "chopping"
|
||||||
@@ -316,118 +361,138 @@ export class GameStore {
|
|||||||
|
|
||||||
const chopInterval = this.chopInterval;
|
const chopInterval = this.chopInterval;
|
||||||
this.chopProgress += deltaTime;
|
this.chopProgress += deltaTime;
|
||||||
while (this.chopProgress >= chopInterval && tree.logsRemaining > 0) {
|
while (this.chopProgress >= chopInterval && !isDepleted(node)) {
|
||||||
this.chopProgress -= chopInterval;
|
this.chopProgress -= chopInterval;
|
||||||
tree.logsRemaining -= 1;
|
const resource = nextResource(node);
|
||||||
|
if (!resource)
|
||||||
|
break;
|
||||||
|
|
||||||
// Eject the log on a ballistic arc to a random spot within a circle around the tree; it
|
node.remaining[resource] -= 1;
|
||||||
// only becomes pickable once it lands (see updateLogFlights/updatePickups).
|
this.ejectDrop(node, resource);
|
||||||
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) {
|
if (isDepleted(node)) {
|
||||||
this.trees = this.trees.filter((t) => t.id !== tree.id);
|
this.nodes = this.nodes.filter((n) => n.id !== node.id);
|
||||||
this.heldTreeId = null;
|
this.heldNodeId = null;
|
||||||
this.chopProgress = 0;
|
this.chopProgress = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Advances every log currently in a ballistic flight (falling from a tree, or flying into the
|
// Ejects a resource on a ballistic arc to a random spot within a circle around the node; it
|
||||||
* inventory once collected), landing/collecting it once its flight completes. Mutates logs in
|
// only becomes pickable once it lands (see updateDropFlights/updatePickups).
|
||||||
* place and only reassigns the `logDrops` array when one is actually removed (collected) — a
|
private ejectDrop(node: ResourceNode, resource: ResourceType) {
|
||||||
* landed log sitting idle, or the array being rebuilt every tick regardless, would otherwise
|
const angle = Math.random() * Math.PI * 2;
|
||||||
* force every observer of `logDrops` (i.e. the whole map) to re-render every animation frame. */
|
const landDistance = Math.random() * DROP_EJECT_RADIUS;
|
||||||
private updateLogFlights(deltaTime: number) {
|
const toX = node.x + Math.cos(angle) * landDistance;
|
||||||
if (this.logDrops.length === 0)
|
const toY = node.y + Math.sin(angle) * landDistance;
|
||||||
|
this.resourceDrops.push({
|
||||||
|
id: randomId('drop'),
|
||||||
|
resource,
|
||||||
|
x: node.x,
|
||||||
|
y: node.y,
|
||||||
|
z: 0,
|
||||||
|
phase: 'falling',
|
||||||
|
flight: {
|
||||||
|
fromX: node.x,
|
||||||
|
fromY: node.y,
|
||||||
|
toX,
|
||||||
|
toY,
|
||||||
|
elapsed: 0,
|
||||||
|
duration: DROP_FALL_DURATION,
|
||||||
|
arcHeight: DROP_FALL_ARC_HEIGHT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advances every resource drop currently in a ballistic flight (falling from a node, or flying
|
||||||
|
* into the inventory once collected), landing/collecting it once its flight completes. Mutates
|
||||||
|
* drops in place and only reassigns the `resourceDrops` array when one is actually removed
|
||||||
|
* (collected) — a landed drop sitting idle, or the array being rebuilt every tick regardless,
|
||||||
|
* would otherwise force every observer of `resourceDrops` (i.e. the whole map) to re-render
|
||||||
|
* every animation frame. */
|
||||||
|
private updateDropFlights(deltaTime: number) {
|
||||||
|
if (this.resourceDrops.length === 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let collectedIds: Set<string> | null = null;
|
let collectedIds: Set<string> | null = null;
|
||||||
for (const log of this.logDrops) {
|
for (const drop of this.resourceDrops) {
|
||||||
if (log.phase === 'landed')
|
if (drop.phase === 'landed')
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const flight = log.flight!;
|
const flight = drop.flight!;
|
||||||
flight.elapsed += deltaTime;
|
flight.elapsed += deltaTime;
|
||||||
const t = Math.min(1, flight.elapsed / flight.duration);
|
const t = Math.min(1, flight.elapsed / flight.duration);
|
||||||
log.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
drop.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
||||||
log.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
drop.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
||||||
log.z = flight.arcHeight * 4 * t * (1 - t);
|
drop.z = flight.arcHeight * 4 * t * (1 - t);
|
||||||
|
|
||||||
if (t < 1)
|
if (t < 1)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (log.phase === 'falling') {
|
if (drop.phase === 'falling') {
|
||||||
log.phase = 'landed';
|
drop.phase = 'landed';
|
||||||
log.flight = undefined;
|
drop.flight = undefined;
|
||||||
log.z = 0;
|
drop.z = 0;
|
||||||
} else {
|
} else {
|
||||||
// Collecting flight finished: the log has reached the character.
|
// Collecting flight finished: the resource has reached the character.
|
||||||
(collectedIds ??= new Set()).add(log.id);
|
(collectedIds ??= new Set()).add(drop.id);
|
||||||
|
if (drop.resource === 'log')
|
||||||
this.inventory.logs += 1;
|
this.inventory.logs += 1;
|
||||||
|
else
|
||||||
|
this.inventory.twigs += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collectedIds)
|
if (collectedIds)
|
||||||
this.logDrops = this.logDrops.filter((log) => !collectedIds!.has(log.id));
|
this.resourceDrops = this.resourceDrops.filter((drop) => !collectedIds!.has(drop.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts a landed log flying into the inventory once the character gets close enough. */
|
/** Starts a landed drop flying into the inventory once the character gets close enough. */
|
||||||
private updatePickups() {
|
private updatePickups() {
|
||||||
const pickupRadius = this.pickupRadiusPx;
|
const pickupRadius = this.pickupRadiusPx;
|
||||||
for (const log of this.logDrops) {
|
for (const drop of this.resourceDrops) {
|
||||||
if (log.phase !== 'landed')
|
if (drop.phase !== 'landed')
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const distance = Math.hypot(log.x - this.character.x, log.y - this.character.y);
|
const distance = Math.hypot(drop.x - this.character.x, drop.y - this.character.y);
|
||||||
if (distance > pickupRadius)
|
if (distance > pickupRadius)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
log.phase = 'collecting';
|
drop.phase = 'collecting';
|
||||||
log.flight = {
|
drop.flight = {
|
||||||
fromX: log.x,
|
fromX: drop.x,
|
||||||
fromY: log.y,
|
fromY: drop.y,
|
||||||
toX: this.character.x,
|
toX: this.character.x,
|
||||||
toY: this.character.y,
|
toY: this.character.y,
|
||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
duration: LOG_COLLECT_DURATION,
|
duration: DROP_COLLECT_DURATION,
|
||||||
arcHeight: LOG_COLLECT_ARC_HEIGHT,
|
arcHeight: DROP_COLLECT_ARC_HEIGHT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Converts held resources into coins. Every resource type that still has stock loses one unit
|
||||||
|
* and pays out its coin value on the same interval, so logs and twigs drain side by side rather
|
||||||
|
* than one type queuing behind the other. */
|
||||||
private updateCoinConversion(deltaTime: number) {
|
private updateCoinConversion(deltaTime: number) {
|
||||||
if (this.inventory.logs <= 0) {
|
if (this.inventory.logs <= 0 && this.inventory.twigs <= 0) {
|
||||||
this.coinProgress = 0;
|
this.coinProgress = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coinsPerLog = this.coinsPerLog;
|
const interval = this.coinConversionInterval;
|
||||||
const conversionInterval = this.coinConversionInterval;
|
|
||||||
this.coinProgress += deltaTime;
|
this.coinProgress += deltaTime;
|
||||||
while (this.coinProgress >= conversionInterval && this.inventory.logs > 0) {
|
while (this.coinProgress >= interval && (this.inventory.logs > 0 || this.inventory.twigs > 0)) {
|
||||||
this.coinProgress -= conversionInterval;
|
this.coinProgress -= interval;
|
||||||
|
if (this.inventory.logs > 0) {
|
||||||
this.inventory.logs -= 1;
|
this.inventory.logs -= 1;
|
||||||
this.inventory.coins += coinsPerLog;
|
this.inventory.coins += this.coinsPerLog;
|
||||||
|
}
|
||||||
|
if (this.inventory.twigs > 0) {
|
||||||
|
this.inventory.twigs -= 1;
|
||||||
|
this.inventory.coins += this.coinsPerTwig;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-18
@@ -1,33 +1,40 @@
|
|||||||
/** Number of trees spawned on a given level. */
|
/** Number of trees spawned on a given level. */
|
||||||
export function treesForLevel(level: number): number {
|
export function treesForLevel(level: number): number {
|
||||||
return level + 4;
|
return Math.max(0, level - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of bushes spawned on a given level. */
|
||||||
|
export function bushesForLevel(level: number): number {
|
||||||
|
return Math.ceil(level / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LOGS_PER_TREE = 5;
|
export const LOGS_PER_TREE = 5;
|
||||||
|
export const TWIGS_PER_TREE = 2;
|
||||||
|
export const TWIGS_PER_BUSH = 5;
|
||||||
|
|
||||||
/** How close the character must get to a tree's center to start chopping it. */
|
/** How close the character must get to a node's center to start chopping it. */
|
||||||
export const INTERACTION_RADIUS = 56;
|
export const INTERACTION_RADIUS = 56;
|
||||||
|
|
||||||
/** Radius of the circle around a tree that ejected logs can land in. */
|
/** Radius of the circle around a node that ejected resources can land in. */
|
||||||
export const LOG_EJECT_RADIUS = 70;
|
export const DROP_EJECT_RADIUS = 70;
|
||||||
|
|
||||||
/** Seconds for a log's ballistic flight from the tree to its landing spot. */
|
/** Seconds for a resource's ballistic flight from the node to its landing spot. */
|
||||||
export const LOG_FALL_DURATION = 0.5;
|
export const DROP_FALL_DURATION = 0.5;
|
||||||
|
|
||||||
/** Peak height (px) of a log's arc while falling from the tree. */
|
/** Peak height (px) of a resource's arc while falling from the node. */
|
||||||
export const LOG_FALL_ARC_HEIGHT = 40;
|
export const DROP_FALL_ARC_HEIGHT = 40;
|
||||||
|
|
||||||
/** Seconds for a landed log's flight into the character's inventory. */
|
/** Seconds for a landed resource's flight into the character's inventory. */
|
||||||
export const LOG_COLLECT_DURATION = 0.25;
|
export const DROP_COLLECT_DURATION = 0.25;
|
||||||
|
|
||||||
/** Peak height (px) of a log's little hop while flying into the inventory. */
|
/** Peak height (px) of a resource's little hop while flying into the inventory. */
|
||||||
export const LOG_COLLECT_ARC_HEIGHT = 16;
|
export const DROP_COLLECT_ARC_HEIGHT = 16;
|
||||||
|
|
||||||
/** Trees won't spawn closer than this to the character's starting position. */
|
/** Nodes (trees/bushes) won't spawn closer than this to the character's starting position. */
|
||||||
export const TREE_MIN_DISTANCE_FROM_CENTER = 140;
|
export const ENTITY_MIN_DISTANCE_FROM_CENTER = 140;
|
||||||
|
|
||||||
/** Trees won't spawn closer than this to each other. */
|
/** Nodes won't spawn closer than this to each other, regardless of kind. */
|
||||||
export const TREE_MIN_DISTANCE_BETWEEN = 100;
|
export const ENTITY_MIN_DISTANCE_BETWEEN = 100;
|
||||||
|
|
||||||
/** Trees won't spawn within this many px of the map edge. */
|
/** Nodes won't spawn within this many px of the map edge. */
|
||||||
export const TREE_EDGE_MARGIN = 70;
|
export const ENTITY_EDGE_MARGIN = 70;
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
|
import type { Inventory, ResourceDrop, ResourceNode, Vec2 } from './types';
|
||||||
import type { UpgradeId } from './upgrades';
|
import type { UpgradeId } from './upgrades';
|
||||||
|
|
||||||
const STORAGE_KEY = 'forest-save-v1';
|
const STORAGE_KEY = 'forest-save-v1';
|
||||||
|
|
||||||
export interface SaveData {
|
export interface SaveData {
|
||||||
version: 1;
|
version: 2;
|
||||||
inventory: Inventory;
|
inventory: Inventory;
|
||||||
upgradeLevels: Record<UpgradeId, number>;
|
upgradeLevels: Record<UpgradeId, number>;
|
||||||
/** World state (character/trees/logs) is optional so older saves without it still load fine —
|
/** World state (character/nodes/drops) is optional so older saves without it still load fine —
|
||||||
* progress is restored either way, just with a freshly spawned world. */
|
* progress is restored either way, just with a freshly spawned world. */
|
||||||
character?: Vec2;
|
character?: Vec2;
|
||||||
trees?: TreeState[];
|
nodes?: ResourceNode[];
|
||||||
logDrops?: LogDrop[];
|
resourceDrops?: ResourceDrop[];
|
||||||
/** Absent on older saves, which are treated as level 1. */
|
/** Absent on older saves, which are treated as level 1. */
|
||||||
level?: number;
|
level?: number;
|
||||||
}
|
}
|
||||||
@@ -21,15 +21,17 @@ function isValidSave(value: unknown): value is SaveData {
|
|||||||
return false;
|
return false;
|
||||||
const data = value as Partial<SaveData>;
|
const data = value as Partial<SaveData>;
|
||||||
return (
|
return (
|
||||||
data.version === 1
|
data.version === 2
|
||||||
&& typeof data.inventory === 'object' && data.inventory !== null
|
&& typeof data.inventory === 'object' && data.inventory !== null
|
||||||
&& typeof data.inventory.logs === 'number'
|
&& typeof data.inventory.logs === 'number'
|
||||||
|
&& typeof data.inventory.twigs === 'number'
|
||||||
&& typeof data.inventory.coins === 'number'
|
&& typeof data.inventory.coins === 'number'
|
||||||
&& typeof data.upgradeLevels === 'object' && data.upgradeLevels !== null
|
&& typeof data.upgradeLevels === 'object' && data.upgradeLevels !== null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the saved game from localStorage, or null if there's none / it's corrupted. */
|
/** Reads the saved game from localStorage, or null if there's none / it's corrupted (including a
|
||||||
|
* save from an older, incompatible schema version — that's treated the same as no save). */
|
||||||
export function loadSave(): SaveData | null {
|
export function loadSave(): SaveData | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|||||||
+79
-12
@@ -81,30 +81,97 @@ export const TREE_SPRITE: PixelGrid = grid(
|
|||||||
TREE_PALETTE,
|
TREE_PALETTE,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const BUSH_PALETTE = {
|
||||||
|
O: '#1b3312',
|
||||||
|
G: '#5a9c3e',
|
||||||
|
g: '#3f7a2a',
|
||||||
|
H: '#a3d977',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same canopy shape as the tree's foliage (minus its trunk), just recolored and rendered
|
||||||
|
// smaller — a bush is basically a squat tree.
|
||||||
|
export const BUSH_SPRITE: PixelGrid = grid(
|
||||||
|
[
|
||||||
|
'......OggO......',
|
||||||
|
'....OGGggGGO....',
|
||||||
|
'..OGGGGggGGGGO..',
|
||||||
|
'.OGgGGGHHGGGgGO.',
|
||||||
|
'OGgGGGGHHGGGGgGO',
|
||||||
|
'OGGgGGGggGGGgGGO',
|
||||||
|
'OGgGGGgGGgGGGgGO',
|
||||||
|
'OGGgGGGGGGGGgGGO',
|
||||||
|
'OGgGGGGggGGGGgGO',
|
||||||
|
'.OGgGGGggGGGgGO.',
|
||||||
|
'..OGgGGggGGgGO..',
|
||||||
|
'...OGgGggGgGO...',
|
||||||
|
'....OGgGGgGO....',
|
||||||
|
'......OggO......',
|
||||||
|
],
|
||||||
|
BUSH_PALETTE,
|
||||||
|
);
|
||||||
|
|
||||||
const LOG_PALETTE = {
|
const LOG_PALETTE = {
|
||||||
O: '#2a1a0d',
|
O: '#2a1a0d',
|
||||||
B: '#7a4a2b',
|
B: '#7a4a2b',
|
||||||
C: '#a9703f',
|
C: '#a9703f',
|
||||||
|
// Lighter sunlit top and darker shaded underside give the log roundness instead of a flat
|
||||||
|
// plank look; scattered flecks break up the grain into rough bark texture; the knot (a
|
||||||
|
// branch scar) and the cut end's growth rings both read as a real piece of wood.
|
||||||
|
H: '#c9915a',
|
||||||
|
S: '#5c3820',
|
||||||
|
F: '#3d2818',
|
||||||
|
K: '#241505',
|
||||||
|
R: '#8a5a34',
|
||||||
|
D: '#150c04',
|
||||||
|
W: '#e8c99a',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LOG_SPRITE: PixelGrid = grid(
|
export const LOG_SPRITE: PixelGrid = grid(
|
||||||
[
|
[
|
||||||
'..OOOOOOOOOOOOOOOO..',
|
'.OOOOOOOOOOOOOOOOOO.',
|
||||||
'OBBBBBBBBBBBBBBBBBBO',
|
'OHHHHHHHHHHHHHHHOOHO',
|
||||||
'OBCCCCCCCCCCCCCCCCBO',
|
'OHHFHHHHHHHHHHHOCCOO',
|
||||||
'OBBBBBBBBBBBBBBBBBBO',
|
'OCCCCKKKCCCCCCOCWWOO',
|
||||||
'OBCCCCCCCCCCCCCCCCBO',
|
'OBFBKRRRKFBBBBOWCWCO',
|
||||||
'OBBBBBBBBBBBBBBBBBBO',
|
'OCCCKRDRKCCCCCOWWCCO',
|
||||||
'OBCCCCCCCCCCCCCCCCBO',
|
'OBBBKRRRKBBBBBOWDCCO',
|
||||||
'OBBBBBBBBBBBBBBBBBBO',
|
'OCCCCKKKCCCCCCOWCWCO',
|
||||||
'OBCCCCCCCCCCCCCCCCBO',
|
'OBBFBBBBBBFBBBOCWWOO',
|
||||||
'OBBBBBBBBBBBBBBBBBBO',
|
'OSSSSSFSSSSSSSSOCCOO',
|
||||||
'OBCCCCCCCCCCCCCCCCBO',
|
'OSSSSSSSSSSSSSSSOOSO',
|
||||||
'..OOOOOOOOOOOOOOOO..',
|
'.OOOOOOOOOOOOOOOOOO.',
|
||||||
],
|
],
|
||||||
LOG_PALETTE,
|
LOG_PALETTE,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const TWIG_PALETTE = {
|
||||||
|
O: '#241708',
|
||||||
|
T: '#8a5a34',
|
||||||
|
L: '#5a9c3e',
|
||||||
|
};
|
||||||
|
|
||||||
|
// A forked twig: a zigzagging main stem (it bends partway up, like a real snapped branch) with a
|
||||||
|
// shorter side-fork breaking off it and a small leaf bud at the tip.
|
||||||
|
export const TWIG_SPRITE: PixelGrid = grid(
|
||||||
|
[
|
||||||
|
'..........LL...',
|
||||||
|
'..........OTL..',
|
||||||
|
'.........OTO...',
|
||||||
|
'........OTO....',
|
||||||
|
'.......OTO.....',
|
||||||
|
'.OT...OTO......',
|
||||||
|
'..OT.OTO.......',
|
||||||
|
'...OTOTO.......',
|
||||||
|
'....OTO........',
|
||||||
|
'....OTO........',
|
||||||
|
'...OTO.........',
|
||||||
|
'..OTO..........',
|
||||||
|
'.OTO...........',
|
||||||
|
'OTO............',
|
||||||
|
],
|
||||||
|
TWIG_PALETTE,
|
||||||
|
);
|
||||||
|
|
||||||
const COIN_PALETTE = {
|
const COIN_PALETTE = {
|
||||||
Y: '#f5c518',
|
Y: '#f5c518',
|
||||||
D: '#c99a0e',
|
D: '#c99a0e',
|
||||||
|
|||||||
+14
-7
@@ -3,16 +3,21 @@ export interface Vec2 {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeState {
|
export type ResourceType = 'log' | 'twig';
|
||||||
|
export type NodeKind = 'tree' | 'bush';
|
||||||
|
|
||||||
|
export interface ResourceNode {
|
||||||
id: string;
|
id: string;
|
||||||
|
kind: NodeKind;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
logsRemaining: number;
|
/** Remaining yield per resource type; the node disappears once every count reaches 0. */
|
||||||
|
remaining: Record<ResourceType, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogPhase = 'falling' | 'landed' | 'collecting';
|
export type DropPhase = 'falling' | 'landed' | 'collecting';
|
||||||
|
|
||||||
export interface LogFlight {
|
export interface Flight {
|
||||||
fromX: number;
|
fromX: number;
|
||||||
fromY: number;
|
fromY: number;
|
||||||
toX: number;
|
toX: number;
|
||||||
@@ -22,17 +27,19 @@ export interface LogFlight {
|
|||||||
arcHeight: number;
|
arcHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogDrop {
|
export interface ResourceDrop {
|
||||||
id: string;
|
id: string;
|
||||||
|
resource: ResourceType;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
/** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */
|
/** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */
|
||||||
z: number;
|
z: number;
|
||||||
phase: LogPhase;
|
phase: DropPhase;
|
||||||
flight?: LogFlight;
|
flight?: Flight;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Inventory {
|
export interface Inventory {
|
||||||
logs: number;
|
logs: number;
|
||||||
|
twigs: number;
|
||||||
coins: number;
|
coins: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -1,4 +1,4 @@
|
|||||||
export type UpgradeId = 'moveSpeed' | 'chopSpeed' | 'logValue' | 'pickupRadius' | 'coinSpeed';
|
export type UpgradeId = 'moveSpeed' | 'chopSpeed' | 'logValue' | 'twigValue' | 'pickupRadius' | 'coinSpeed';
|
||||||
|
|
||||||
export interface UpgradeDef {
|
export interface UpgradeDef {
|
||||||
id: UpgradeId;
|
id: UpgradeId;
|
||||||
@@ -35,6 +35,14 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
|||||||
effectAtLevel: (level) => level,
|
effectAtLevel: (level) => level,
|
||||||
formatEffect: (v) => `${v} монет/бревно`,
|
formatEffect: (v) => `${v} монет/бревно`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'twigValue',
|
||||||
|
name: 'Стоимость ветки',
|
||||||
|
baseCost: 5,
|
||||||
|
costGrowth: 1.3,
|
||||||
|
effectAtLevel: (level) => level * 0.1,
|
||||||
|
formatEffect: (v) => `${v.toFixed(2)} монет/ветка`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pickupRadius',
|
id: 'pickupRadius',
|
||||||
name: 'Радиус подбора',
|
name: 'Радиус подбора',
|
||||||
@@ -48,8 +56,10 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
|||||||
name: 'Скорость обмена на монеты',
|
name: 'Скорость обмена на монеты',
|
||||||
baseCost: 7,
|
baseCost: 7,
|
||||||
costGrowth: 1.35,
|
costGrowth: 1.35,
|
||||||
effectAtLevel: (level) => Math.pow(1.15, level - 1),
|
// Seconds to convert one unit of a resource into coins — every held resource type converts
|
||||||
formatEffect: (v) => `${v.toFixed(2)} бревен/с`,
|
// on this same cadence at once, so this is the only knob for how fast any of them drain.
|
||||||
|
effectAtLevel: (level) => 1 / Math.pow(1.15, level - 1),
|
||||||
|
formatEffect: (v) => `${v.toFixed(2)} с/ресурс`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,28 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* This is a fixed-landscape game. On a touchscreen held in portrait, rotate the whole app into
|
||||||
|
* landscape instead of showing a cramped/wrong-aspect portrait layout — screen.orientation.lock
|
||||||
|
* (see main.tsx) covers the cases it can, this CSS covers everywhere else. */
|
||||||
|
@media screen and (orientation: portrait) and (pointer: coarse) {
|
||||||
|
#root {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vh;
|
||||||
|
height: 100vw;
|
||||||
|
transform-origin: top left;
|
||||||
|
transform: rotate(90deg) translate(0, -100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
// Best-effort: the browser only grants this in contexts like fullscreen or an installed PWA, and
|
||||||
|
// throws everywhere else — the CSS rotate fallback in index.css covers the rest.
|
||||||
|
screen.orientation?.lock?.('landscape').catch(() => {})
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
Reference in New Issue
Block a user