ветки и кусты
This commit is contained in:
+208
-143
@@ -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[] {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const trees: TreeState[] = [];
|
||||
let attempts = 0;
|
||||
const treeCount = treesForLevel(this.level);
|
||||
/** 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);
|
||||
|
||||
while (trees.length < treeCount && 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 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 },
|
||||
}));
|
||||
|
||||
if (Math.hypot(x - centerX, y - centerY) < TREE_MIN_DISTANCE_FROM_CENTER)
|
||||
continue;
|
||||
if (trees.some((tree) => Math.hypot(tree.x - x, tree.y - y) < TREE_MIN_DISTANCE_BETWEEN))
|
||||
continue;
|
||||
|
||||
trees.push({ id: randomId('tree'), x, y, logsRemaining: LOGS_PER_TREE });
|
||||
}
|
||||
|
||||
return trees;
|
||||
return [...trees, ...bushes];
|
||||
}
|
||||
|
||||
holdTree(id: string) {
|
||||
if (this.trees.some((tree) => tree.id === id)) {
|
||||
private spawnPositions(count: number, width: number, height: number, existing: Vec2[]): Vec2[] {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const positions: Vec2[] = [];
|
||||
let attempts = 0;
|
||||
|
||||
while (positions.length < count && attempts < 1000) {
|
||||
attempts++;
|
||||
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) < ENTITY_MIN_DISTANCE_FROM_CENTER)
|
||||
continue;
|
||||
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;
|
||||
|
||||
positions.push({ x, y });
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
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);
|
||||
this.inventory.logs += 1;
|
||||
// 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;
|
||||
this.inventory.logs -= 1;
|
||||
this.inventory.coins += coinsPerLog;
|
||||
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 += 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)} с/ресурс`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user