ветки и кусты
This commit is contained in:
+4
-1
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
+26
-1
@@ -155,6 +155,15 @@
|
||||
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 {
|
||||
width: 64px;
|
||||
height: 32px;
|
||||
@@ -164,6 +173,15 @@
|
||||
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 {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
@@ -180,7 +198,14 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+74
-30
@@ -2,41 +2,57 @@ 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 type { ResourceDrop, ResourceNode } from '../game/types';
|
||||
import { BUSH_SPRITE, CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE, TWIG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
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
|
||||
// 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 (
|
||||
<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 className={`entity ${drop.resource}-shadow`} style={{ left: drop.x, top: drop.y }} />
|
||||
<div
|
||||
className={`entity ${drop.resource}`}
|
||||
style={{ left: drop.x, top: drop.y - drop.z, zIndex: Math.floor(drop.y - drop.z) }}
|
||||
>
|
||||
<PixelSprite pixels={DROP_SPRITES[drop.resource]} size={DROP_SIZES[drop.resource]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TreeEntity = observer(function TreeEntity({ tree, store }: { tree: TreeState; store: GameStore }) {
|
||||
const isChopping = tree.id === store.heldTreeId && store.isChopping;
|
||||
const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE };
|
||||
const NODE_SIZES = { tree: 64, bush: 40 };
|
||||
|
||||
const NodeEntity = observer(function NodeEntity({ node, store }: { node: ResourceNode; store: GameStore }) {
|
||||
const isChopping = node.id === store.heldNodeId && store.isChopping;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="entity 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
|
||||
className={`entity tree ${isChopping ? 'tree--chopping' : ''}`}
|
||||
style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) }}
|
||||
data-tree-id={tree.id}
|
||||
className={`entity ${node.kind} ${isChopping ? 'chopping' : ''}`}
|
||||
style={{ left: node.x, top: node.y, zIndex: Math.floor(node.y) }}
|
||||
data-node-id={node.id}
|
||||
>
|
||||
<PixelSprite pixels={TREE_SPRITE} size={64} />
|
||||
<PixelSprite pixels={NODE_SPRITES[node.kind]} size={NODE_SIZES[node.kind]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -62,13 +78,27 @@ const CharacterEntity = observer(function CharacterEntity({ store }: { store: Ga
|
||||
function GameMap({ store }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
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(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container)
|
||||
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) {
|
||||
store.initialize(width, height);
|
||||
store.start();
|
||||
@@ -90,31 +120,45 @@ function GameMap({ store }: Props) {
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
/** Finds the tree (if any) the pointer is currently over, by DOM hit-testing at its client
|
||||
* coordinates. */
|
||||
function treeIdAtPoint(clientX: number, clientY: number): string | null {
|
||||
const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-tree-id]');
|
||||
return hit?.getAttribute('data-tree-id') ?? null;
|
||||
/** Finds the node (if any) the pointer is currently over, by DOM hit-testing at its client
|
||||
* coordinates. elementFromPoint uses physical screen coordinates and already accounts for any
|
||||
* CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */
|
||||
function nodeIdAtPoint(clientX: number, clientY: number): string | null {
|
||||
const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-node-id]');
|
||||
return hit?.getAttribute('data-node-id') ?? null;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* 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
|
||||
* cursor across a tree mid-drag starts/stops chopping it, and releasing the button stops it. */
|
||||
* now — a node to chop, or open ground to walk to. Shared by pointerdown/move/up so panning the
|
||||
* cursor across a node mid-drag starts/stops chopping it, and releasing the button stops it. */
|
||||
function followPointer(e: PointerEvent<HTMLDivElement>) {
|
||||
if ((e.buttons & 1) === 0) {
|
||||
store.release();
|
||||
return;
|
||||
}
|
||||
|
||||
const treeId = treeIdAtPoint(e.clientX, e.clientY);
|
||||
if (treeId) {
|
||||
store.holdTree(treeId);
|
||||
const nodeId = nodeIdAtPoint(e.clientX, e.clientY);
|
||||
if (nodeId) {
|
||||
store.holdNode(nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -133,12 +177,12 @@ function GameMap({ store }: Props) {
|
||||
>
|
||||
{ready && (
|
||||
<>
|
||||
{store.logDrops.map((log) => (
|
||||
<LogEntity key={log.id} log={log} />
|
||||
{store.resourceDrops.map((drop) => (
|
||||
<ResourceDropEntity key={drop.id} drop={drop} />
|
||||
))}
|
||||
|
||||
{store.trees.map((tree) => (
|
||||
<TreeEntity key={tree.id} tree={tree} store={store} />
|
||||
{store.nodes.map((node) => (
|
||||
<NodeEntity key={node.id} node={node} store={store} />
|
||||
))}
|
||||
|
||||
<CharacterEntity store={store} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -15,13 +15,17 @@ function Topbar({ store }: Props) {
|
||||
<span>Уровень {store.level}</span>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={TWIG_SPRITE} size={22} />
|
||||
<span>{store.inventory.twigs}</span>
|
||||
</div>
|
||||
<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>
|
||||
<span>{store.inventory.coins.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+197
-132
@@ -1,17 +1,20 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
|
||||
import type { Inventory, ResourceDrop, ResourceNode, ResourceType, Vec2 } from './types';
|
||||
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,
|
||||
LOG_COLLECT_ARC_HEIGHT,
|
||||
LOG_COLLECT_DURATION,
|
||||
LOG_EJECT_RADIUS,
|
||||
LOG_FALL_ARC_HEIGHT,
|
||||
LOG_FALL_DURATION,
|
||||
LOGS_PER_TREE,
|
||||
TREE_EDGE_MARGIN,
|
||||
TREE_MIN_DISTANCE_BETWEEN,
|
||||
TREE_MIN_DISTANCE_FROM_CENTER,
|
||||
treesForLevel,
|
||||
TWIGS_PER_BUSH,
|
||||
TWIGS_PER_TREE,
|
||||
} from './constants';
|
||||
import { loadSave, writeSave } from './persistence';
|
||||
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. */
|
||||
const AUTOSAVE_INTERVAL = 2;
|
||||
|
||||
/** A node yields its logs before its twigs. */
|
||||
const RESOURCE_ORDER: ResourceType[] = ['log', 'twig'];
|
||||
|
||||
function randomId(prefix: string): string {
|
||||
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 {
|
||||
level = 1;
|
||||
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. */
|
||||
nodes: ResourceNode[] = [];
|
||||
resourceDrops: ResourceDrop[] = [];
|
||||
inventory: Inventory = { logs: 0, twigs: 0, coins: 0 };
|
||||
heldNodeId: string | null = null;
|
||||
/** Free-form walk destination set by pressing on empty map, mutually exclusive with heldNodeId. */
|
||||
moveTarget: Vec2 | null = null;
|
||||
upgradeLevels: Record<UpgradeId, number> = {
|
||||
moveSpeed: 1,
|
||||
chopSpeed: 1,
|
||||
logValue: 1,
|
||||
twigValue: 1,
|
||||
pickupRadius: 1,
|
||||
coinSpeed: 1,
|
||||
};
|
||||
@@ -45,7 +61,7 @@ export class GameStore {
|
||||
private saveProgress = 0;
|
||||
private lastTimestamp: 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;
|
||||
|
||||
constructor() {
|
||||
@@ -60,21 +76,21 @@ export class GameStore {
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
get heldTree(): TreeState | undefined {
|
||||
return this.trees.find((tree) => tree.id === this.heldTreeId);
|
||||
get heldNode(): ResourceNode | undefined {
|
||||
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 {
|
||||
const tree = this.heldTree;
|
||||
if (!tree)
|
||||
const node = this.heldNode;
|
||||
if (!node)
|
||||
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 {
|
||||
if (this.heldTreeId)
|
||||
if (this.heldNodeId)
|
||||
return !this.isChopping;
|
||||
if (this.moveTarget)
|
||||
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 {
|
||||
const logsPerSecond = UPGRADES.chopSpeed.effectAtLevel(this.upgradeLevels.chopSpeed);
|
||||
return 1 / logsPerSecond;
|
||||
const perSecond = UPGRADES.chopSpeed.effectAtLevel(this.upgradeLevels.chopSpeed);
|
||||
return 1 / perSecond;
|
||||
}
|
||||
|
||||
get coinsPerLog(): number {
|
||||
return UPGRADES.logValue.effectAtLevel(this.upgradeLevels.logValue);
|
||||
}
|
||||
|
||||
get coinsPerTwig(): number {
|
||||
return UPGRADES.twigValue.effectAtLevel(this.upgradeLevels.twigValue);
|
||||
}
|
||||
|
||||
get pickupRadiusPx(): number {
|
||||
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
|
||||
}
|
||||
|
||||
/** Seconds to convert one unit of a resource into coins. */
|
||||
get coinConversionInterval(): number {
|
||||
const logsPerSecond = UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
|
||||
return 1 / logsPerSecond;
|
||||
return UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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. */
|
||||
@@ -120,12 +140,12 @@ export class GameStore {
|
||||
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. */
|
||||
/** (Re)initializes the game for a map of the given size. Restores a saved game (character/nodes/
|
||||
* drops/progress) if one exists; otherwise places the character in the center and scatters a
|
||||
* fresh level's worth of trees and bushes. */
|
||||
initialize(width: number, height: number) {
|
||||
this.mapSize = { width, height };
|
||||
this.heldTreeId = null;
|
||||
this.heldNodeId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
this.coinProgress = 0;
|
||||
@@ -134,88 +154,113 @@ export class GameStore {
|
||||
const save = loadSave();
|
||||
this.level = save?.level ?? 1;
|
||||
|
||||
if (save?.character && save.trees && save.logDrops) {
|
||||
if (save?.character && save.nodes && save.resourceDrops) {
|
||||
this.character = { ...save.character };
|
||||
this.trees = save.trees.map((tree) => ({ ...tree }));
|
||||
this.logDrops = save.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined }));
|
||||
this.nodes = save.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } }));
|
||||
this.resourceDrops = save.resourceDrops.map((drop) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined }));
|
||||
} else {
|
||||
this.character = { x: width / 2, y: height / 2 };
|
||||
this.trees = this.spawnTrees(width, height);
|
||||
this.logDrops = [];
|
||||
this.nodes = this.spawnNodes(width, height);
|
||||
this.resourceDrops = [];
|
||||
}
|
||||
|
||||
this.inventory = save ? { ...save.inventory } : { logs: 0, coins: 0 };
|
||||
this.inventory = save ? { ...save.inventory } : { logs: 0, twigs: 0, coins: 0 };
|
||||
if (save)
|
||||
this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels };
|
||||
}
|
||||
|
||||
/** Advances to the next level once every tree is chopped and every log collected: respawns a
|
||||
* fresh batch of trees (level + 4 of them) and clears the ground of logs. */
|
||||
/** Advances to the next level once every node is chopped and every resource collected: respawns
|
||||
* a fresh batch of trees and bushes and clears the ground of drops. */
|
||||
nextLevel() {
|
||||
if (!this.canAdvanceLevel || !this.mapSize)
|
||||
return;
|
||||
|
||||
this.level += 1;
|
||||
this.heldTreeId = null;
|
||||
this.heldNodeId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
this.trees = this.spawnTrees(this.mapSize.width, this.mapSize.height);
|
||||
this.logDrops = [];
|
||||
this.nodes = this.spawnNodes(this.mapSize.width, this.mapSize.height);
|
||||
this.resourceDrops = [];
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Persists the full game (world state + progress) to localStorage right away. */
|
||||
save() {
|
||||
writeSave({
|
||||
version: 1,
|
||||
version: 2,
|
||||
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 })),
|
||||
nodes: this.nodes.map((node) => ({ ...node, remaining: { ...node.remaining } })),
|
||||
resourceDrops: this.resourceDrops.map((drop) => ({ ...drop, flight: drop.flight ? { ...drop.flight } : undefined })),
|
||||
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 centerY = height / 2;
|
||||
const trees: TreeState[] = [];
|
||||
const positions: Vec2[] = [];
|
||||
let attempts = 0;
|
||||
const treeCount = treesForLevel(this.level);
|
||||
|
||||
while (trees.length < treeCount && attempts < 1000) {
|
||||
while (positions.length < 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);
|
||||
const x = ENTITY_EDGE_MARGIN + Math.random() * Math.max(0, width - ENTITY_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;
|
||||
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;
|
||||
|
||||
trees.push({ id: randomId('tree'), x, y, logsRemaining: LOGS_PER_TREE });
|
||||
positions.push({ x, y });
|
||||
}
|
||||
|
||||
return trees;
|
||||
return positions;
|
||||
}
|
||||
|
||||
holdTree(id: string) {
|
||||
if (this.trees.some((tree) => tree.id === id)) {
|
||||
holdNode(id: string) {
|
||||
if (this.nodes.some((node) => node.id === id)) {
|
||||
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). */
|
||||
moveTo(x: number, y: number) {
|
||||
this.heldTreeId = null;
|
||||
this.heldNodeId = null;
|
||||
this.chopProgress = 0;
|
||||
this.moveTarget = { x, y };
|
||||
}
|
||||
|
||||
release() {
|
||||
this.heldTreeId = null;
|
||||
this.heldNodeId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
}
|
||||
@@ -247,7 +292,7 @@ export class GameStore {
|
||||
|
||||
private tick(deltaTime: number) {
|
||||
this.updateMovementAndChopping(deltaTime);
|
||||
this.updateLogFlights(deltaTime);
|
||||
this.updateDropFlights(deltaTime);
|
||||
this.updatePickups();
|
||||
this.updateCoinConversion(deltaTime);
|
||||
this.updateAutosave(deltaTime);
|
||||
@@ -262,9 +307,9 @@ export class GameStore {
|
||||
}
|
||||
|
||||
private updateMovementAndChopping(deltaTime: number) {
|
||||
const tree = this.heldTree;
|
||||
if (tree) {
|
||||
this.walkTowardTree(tree, deltaTime);
|
||||
const node = this.heldNode;
|
||||
if (node) {
|
||||
this.walkTowardNode(node, deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -294,9 +339,9 @@ export class GameStore {
|
||||
};
|
||||
}
|
||||
|
||||
private walkTowardTree(tree: TreeState, deltaTime: number) {
|
||||
const dx = tree.x - this.character.x;
|
||||
const dy = tree.y - this.character.y;
|
||||
private walkTowardNode(node: ResourceNode, deltaTime: number) {
|
||||
const dx = node.x - this.character.x;
|
||||
const dy = node.y - this.character.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
// A small tolerance keeps the character from flip-flopping between "walking" and "chopping"
|
||||
@@ -316,118 +361,138 @@ export class GameStore {
|
||||
|
||||
const chopInterval = this.chopInterval;
|
||||
this.chopProgress += deltaTime;
|
||||
while (this.chopProgress >= chopInterval && tree.logsRemaining > 0) {
|
||||
while (this.chopProgress >= chopInterval && !isDepleted(node)) {
|
||||
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
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
node.remaining[resource] -= 1;
|
||||
this.ejectDrop(node, resource);
|
||||
|
||||
if (tree.logsRemaining <= 0) {
|
||||
this.trees = this.trees.filter((t) => t.id !== tree.id);
|
||||
this.heldTreeId = null;
|
||||
if (isDepleted(node)) {
|
||||
this.nodes = this.nodes.filter((n) => n.id !== node.id);
|
||||
this.heldNodeId = 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)
|
||||
// Ejects a resource on a ballistic arc to a random spot within a circle around the node; it
|
||||
// only becomes pickable once it lands (see updateDropFlights/updatePickups).
|
||||
private ejectDrop(node: ResourceNode, resource: ResourceType) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const landDistance = Math.random() * DROP_EJECT_RADIUS;
|
||||
const toX = node.x + Math.cos(angle) * landDistance;
|
||||
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;
|
||||
|
||||
let collectedIds: Set<string> | null = null;
|
||||
for (const log of this.logDrops) {
|
||||
if (log.phase === 'landed')
|
||||
for (const drop of this.resourceDrops) {
|
||||
if (drop.phase === 'landed')
|
||||
continue;
|
||||
|
||||
const flight = log.flight!;
|
||||
const flight = drop.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);
|
||||
drop.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
||||
drop.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
||||
drop.z = flight.arcHeight * 4 * t * (1 - t);
|
||||
|
||||
if (t < 1)
|
||||
continue;
|
||||
|
||||
if (log.phase === 'falling') {
|
||||
log.phase = 'landed';
|
||||
log.flight = undefined;
|
||||
log.z = 0;
|
||||
if (drop.phase === 'falling') {
|
||||
drop.phase = 'landed';
|
||||
drop.flight = undefined;
|
||||
drop.z = 0;
|
||||
} else {
|
||||
// Collecting flight finished: the log has reached the character.
|
||||
(collectedIds ??= new Set()).add(log.id);
|
||||
// Collecting flight finished: the resource has reached the character.
|
||||
(collectedIds ??= new Set()).add(drop.id);
|
||||
if (drop.resource === 'log')
|
||||
this.inventory.logs += 1;
|
||||
else
|
||||
this.inventory.twigs += 1;
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const pickupRadius = this.pickupRadiusPx;
|
||||
for (const log of this.logDrops) {
|
||||
if (log.phase !== 'landed')
|
||||
for (const drop of this.resourceDrops) {
|
||||
if (drop.phase !== 'landed')
|
||||
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)
|
||||
continue;
|
||||
|
||||
log.phase = 'collecting';
|
||||
log.flight = {
|
||||
fromX: log.x,
|
||||
fromY: log.y,
|
||||
drop.phase = 'collecting';
|
||||
drop.flight = {
|
||||
fromX: drop.x,
|
||||
fromY: drop.y,
|
||||
toX: this.character.x,
|
||||
toY: this.character.y,
|
||||
elapsed: 0,
|
||||
duration: LOG_COLLECT_DURATION,
|
||||
arcHeight: LOG_COLLECT_ARC_HEIGHT,
|
||||
duration: DROP_COLLECT_DURATION,
|
||||
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) {
|
||||
if (this.inventory.logs <= 0) {
|
||||
if (this.inventory.logs <= 0 && this.inventory.twigs <= 0) {
|
||||
this.coinProgress = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const coinsPerLog = this.coinsPerLog;
|
||||
const conversionInterval = this.coinConversionInterval;
|
||||
const interval = this.coinConversionInterval;
|
||||
this.coinProgress += deltaTime;
|
||||
while (this.coinProgress >= conversionInterval && this.inventory.logs > 0) {
|
||||
this.coinProgress -= conversionInterval;
|
||||
while (this.coinProgress >= interval && (this.inventory.logs > 0 || this.inventory.twigs > 0)) {
|
||||
this.coinProgress -= interval;
|
||||
if (this.inventory.logs > 0) {
|
||||
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. */
|
||||
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 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;
|
||||
|
||||
/** Radius of the circle around a tree that ejected logs can land in. */
|
||||
export const LOG_EJECT_RADIUS = 70;
|
||||
/** Radius of the circle around a node that ejected resources can land in. */
|
||||
export const DROP_EJECT_RADIUS = 70;
|
||||
|
||||
/** Seconds for a log's ballistic flight from the tree to its landing spot. */
|
||||
export const LOG_FALL_DURATION = 0.5;
|
||||
/** Seconds for a resource's ballistic flight from the node to its landing spot. */
|
||||
export const DROP_FALL_DURATION = 0.5;
|
||||
|
||||
/** Peak height (px) of a log's arc while falling from the tree. */
|
||||
export const LOG_FALL_ARC_HEIGHT = 40;
|
||||
/** Peak height (px) of a resource's arc while falling from the node. */
|
||||
export const DROP_FALL_ARC_HEIGHT = 40;
|
||||
|
||||
/** Seconds for a landed log's flight into the character's inventory. */
|
||||
export const LOG_COLLECT_DURATION = 0.25;
|
||||
/** Seconds for a landed resource's flight into the character's inventory. */
|
||||
export const DROP_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;
|
||||
/** Peak height (px) of a resource's little hop while flying into the inventory. */
|
||||
export const DROP_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;
|
||||
/** Nodes (trees/bushes) won't spawn closer than this to the character's starting position. */
|
||||
export const ENTITY_MIN_DISTANCE_FROM_CENTER = 140;
|
||||
|
||||
/** Trees won't spawn closer than this to each other. */
|
||||
export const TREE_MIN_DISTANCE_BETWEEN = 100;
|
||||
/** Nodes won't spawn closer than this to each other, regardless of kind. */
|
||||
export const ENTITY_MIN_DISTANCE_BETWEEN = 100;
|
||||
|
||||
/** Trees won't spawn within this many px of the map edge. */
|
||||
export const TREE_EDGE_MARGIN = 70;
|
||||
/** Nodes won't spawn within this many px of the map edge. */
|
||||
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';
|
||||
|
||||
const STORAGE_KEY = 'forest-save-v1';
|
||||
|
||||
export interface SaveData {
|
||||
version: 1;
|
||||
version: 2;
|
||||
inventory: Inventory;
|
||||
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. */
|
||||
character?: Vec2;
|
||||
trees?: TreeState[];
|
||||
logDrops?: LogDrop[];
|
||||
nodes?: ResourceNode[];
|
||||
resourceDrops?: ResourceDrop[];
|
||||
/** Absent on older saves, which are treated as level 1. */
|
||||
level?: number;
|
||||
}
|
||||
@@ -21,15 +21,17 @@ function isValidSave(value: unknown): value is SaveData {
|
||||
return false;
|
||||
const data = value as Partial<SaveData>;
|
||||
return (
|
||||
data.version === 1
|
||||
data.version === 2
|
||||
&& typeof data.inventory === 'object' && data.inventory !== null
|
||||
&& typeof data.inventory.logs === 'number'
|
||||
&& typeof data.inventory.twigs === '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. */
|
||||
/** 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 {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
+79
-12
@@ -81,30 +81,97 @@ export const TREE_SPRITE: PixelGrid = grid(
|
||||
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 = {
|
||||
O: '#2a1a0d',
|
||||
B: '#7a4a2b',
|
||||
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(
|
||||
[
|
||||
'..OOOOOOOOOOOOOOOO..',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'..OOOOOOOOOOOOOOOO..',
|
||||
'.OOOOOOOOOOOOOOOOOO.',
|
||||
'OHHHHHHHHHHHHHHHOOHO',
|
||||
'OHHFHHHHHHHHHHHOCCOO',
|
||||
'OCCCCKKKCCCCCCOCWWOO',
|
||||
'OBFBKRRRKFBBBBOWCWCO',
|
||||
'OCCCKRDRKCCCCCOWWCCO',
|
||||
'OBBBKRRRKBBBBBOWDCCO',
|
||||
'OCCCCKKKCCCCCCOWCWCO',
|
||||
'OBBFBBBBBBFBBBOCWWOO',
|
||||
'OSSSSSFSSSSSSSSOCCOO',
|
||||
'OSSSSSSSSSSSSSSSOOSO',
|
||||
'.OOOOOOOOOOOOOOOOOO.',
|
||||
],
|
||||
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 = {
|
||||
Y: '#f5c518',
|
||||
D: '#c99a0e',
|
||||
|
||||
+14
-7
@@ -3,16 +3,21 @@ export interface Vec2 {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface TreeState {
|
||||
export type ResourceType = 'log' | 'twig';
|
||||
export type NodeKind = 'tree' | 'bush';
|
||||
|
||||
export interface ResourceNode {
|
||||
id: string;
|
||||
kind: NodeKind;
|
||||
x: 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;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
@@ -22,17 +27,19 @@ export interface LogFlight {
|
||||
arcHeight: number;
|
||||
}
|
||||
|
||||
export interface LogDrop {
|
||||
export interface ResourceDrop {
|
||||
id: string;
|
||||
resource: ResourceType;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */
|
||||
z: number;
|
||||
phase: LogPhase;
|
||||
flight?: LogFlight;
|
||||
phase: DropPhase;
|
||||
flight?: Flight;
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
logs: number;
|
||||
twigs: 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 {
|
||||
id: UpgradeId;
|
||||
@@ -35,6 +35,14 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
||||
effectAtLevel: (level) => level,
|
||||
formatEffect: (v) => `${v} монет/бревно`,
|
||||
},
|
||||
{
|
||||
id: 'twigValue',
|
||||
name: 'Стоимость ветки',
|
||||
baseCost: 5,
|
||||
costGrowth: 1.3,
|
||||
effectAtLevel: (level) => level * 0.1,
|
||||
formatEffect: (v) => `${v.toFixed(2)} монет/ветка`,
|
||||
},
|
||||
{
|
||||
id: 'pickupRadius',
|
||||
name: 'Радиус подбора',
|
||||
@@ -48,8 +56,10 @@ export const UPGRADE_LIST: UpgradeDef[] = [
|
||||
name: 'Скорость обмена на монеты',
|
||||
baseCost: 7,
|
||||
costGrowth: 1.35,
|
||||
effectAtLevel: (level) => Math.pow(1.15, level - 1),
|
||||
formatEffect: (v) => `${v.toFixed(2)} бревен/с`,
|
||||
// Seconds to convert one unit of a resource into coins — every held resource type converts
|
||||
// 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%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
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 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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
Reference in New Issue
Block a user