initial commit

This commit is contained in:
azykov@mail.ru
2026-09-04 16:03:17 +03:00
commit a05d5f4547
31 changed files with 2842 additions and 0 deletions
+405
View File
@@ -0,0 +1,405 @@
import { makeAutoObservable } from 'mobx';
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
import {
INTERACTION_RADIUS,
LOG_COLLECT_ARC_HEIGHT,
LOG_COLLECT_DURATION,
LOG_EJECT_RADIUS,
LOG_FALL_ARC_HEIGHT,
LOG_FALL_DURATION,
LOGS_PER_TREE,
TREE_COUNT,
TREE_EDGE_MARGIN,
TREE_MIN_DISTANCE_BETWEEN,
TREE_MIN_DISTANCE_FROM_CENTER,
} from './constants';
import { loadSave, writeSave } from './persistence';
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
/** How often (seconds of played time) the game autosaves while running. */
const AUTOSAVE_INTERVAL = 2;
function randomId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2)}`;
}
export class GameStore {
character: Vec2 = { x: 0, y: 0 };
trees: TreeState[] = [];
logDrops: LogDrop[] = [];
inventory: Inventory = { logs: 0, coins: 0 };
heldTreeId: string | null = null;
/** Free-form walk destination set by pressing on empty map, mutually exclusive with heldTreeId. */
moveTarget: Vec2 | null = null;
upgradeLevels: Record<UpgradeId, number> = {
moveSpeed: 1,
chopSpeed: 1,
logValue: 1,
pickupRadius: 1,
coinSpeed: 1,
};
private chopProgress = 0;
private coinProgress = 0;
private saveProgress = 0;
private lastTimestamp: number | null = null;
private rafId: number | null = null;
constructor() {
// autoBind so methods stay correctly bound when passed as bare references (event handlers,
// e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`).
// Deliberately no side effects (e.g. global event listener registration) here beyond
// makeAutoObservable: React StrictMode double-invokes `useState(() => new GameStore())`'s
// initializer in dev, constructing (and discarding) an extra instance — any listener
// registered in the constructor would leak and fire for that discarded instance too. Global
// listeners like beforeunload belong in a useEffect instead (see GameMap), which StrictMode
// handles correctly via its mount/cleanup/remount cycle.
makeAutoObservable(this, {}, { autoBind: true });
}
get heldTree(): TreeState | undefined {
return this.trees.find((tree) => tree.id === this.heldTreeId);
}
/** Whether the character is currently in chopping range of the held tree (as opposed to still walking to it). */
get isChopping(): boolean {
const tree = this.heldTree;
if (!tree)
return false;
return Math.hypot(tree.x - this.character.x, tree.y - this.character.y) <= INTERACTION_RADIUS;
}
/** Whether the character is actively walking (toward a held tree or a free-form move target). */
get isMoving(): boolean {
if (this.heldTreeId)
return !this.isChopping;
if (this.moveTarget)
return Math.hypot(this.moveTarget.x - this.character.x, this.moveTarget.y - this.character.y) > 0.5;
return false;
}
get characterSpeed(): number {
return UPGRADES.moveSpeed.effectAtLevel(this.upgradeLevels.moveSpeed);
}
get chopInterval(): number {
const logsPerSecond = UPGRADES.chopSpeed.effectAtLevel(this.upgradeLevels.chopSpeed);
return 1 / logsPerSecond;
}
get coinsPerLog(): number {
return UPGRADES.logValue.effectAtLevel(this.upgradeLevels.logValue);
}
get pickupRadiusPx(): number {
return UPGRADES.pickupRadius.effectAtLevel(this.upgradeLevels.pickupRadius);
}
get coinConversionInterval(): number {
const logsPerSecond = UPGRADES.coinSpeed.effectAtLevel(this.upgradeLevels.coinSpeed);
return 1 / logsPerSecond;
}
/** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */
buyUpgrade(id: UpgradeId): boolean {
const level = this.upgradeLevels[id];
const cost = costForNextLevel(UPGRADES[id], level);
if (this.inventory.coins < cost)
return false;
this.inventory.coins -= cost;
this.upgradeLevels[id] = level + 1;
return true;
}
/** (Re)initializes the game for a map of the given size. Restores a saved game (character/trees/
* logs/progress) if one exists; otherwise places the character in the center and scatters
* fresh trees. */
initialize(width: number, height: number) {
this.heldTreeId = null;
this.moveTarget = null;
this.chopProgress = 0;
this.coinProgress = 0;
this.saveProgress = 0;
const save = loadSave();
if (save?.character && save.trees && save.logDrops) {
this.character = { ...save.character };
this.trees = save.trees.map((tree) => ({ ...tree }));
this.logDrops = save.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined }));
} else {
this.character = { x: width / 2, y: height / 2 };
this.trees = this.spawnTrees(width, height);
this.logDrops = [];
}
this.inventory = save ? { ...save.inventory } : { logs: 0, coins: 0 };
if (save)
this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels };
}
/** Persists the full game (world state + progress) to localStorage right away. */
save() {
writeSave({
version: 1,
inventory: { ...this.inventory },
upgradeLevels: { ...this.upgradeLevels },
character: { ...this.character },
trees: this.trees.map((tree) => ({ ...tree })),
logDrops: this.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined })),
});
}
private spawnTrees(width: number, height: number): TreeState[] {
const centerX = width / 2;
const centerY = height / 2;
const trees: TreeState[] = [];
let attempts = 0;
while (trees.length < TREE_COUNT && attempts < 1000) {
attempts++;
const x = TREE_EDGE_MARGIN + Math.random() * Math.max(0, width - TREE_EDGE_MARGIN * 2);
const y = TREE_EDGE_MARGIN + Math.random() * Math.max(0, height - TREE_EDGE_MARGIN * 2);
if (Math.hypot(x - centerX, y - centerY) < TREE_MIN_DISTANCE_FROM_CENTER)
continue;
if (trees.some((tree) => Math.hypot(tree.x - x, tree.y - y) < TREE_MIN_DISTANCE_BETWEEN))
continue;
trees.push({ id: randomId('tree'), x, y, logsRemaining: LOGS_PER_TREE });
}
return trees;
}
holdTree(id: string) {
if (this.trees.some((tree) => tree.id === id)) {
this.moveTarget = null;
this.heldTreeId = id;
}
}
/** Starts walking freely toward an arbitrary point on the map (e.g. a click on empty ground). */
moveTo(x: number, y: number) {
this.heldTreeId = null;
this.chopProgress = 0;
this.moveTarget = { x, y };
}
release() {
this.heldTreeId = null;
this.moveTarget = null;
this.chopProgress = 0;
}
start() {
if (this.rafId !== null)
return;
this.lastTimestamp = null;
const loop = (timestamp: number) => {
const last = this.lastTimestamp ?? timestamp;
// Cap the per-frame step so a stalled/backgrounded tab doesn't cause a huge catch-up jump
// (e.g. character teleporting across the map) once it resumes.
const deltaTime = Math.min((timestamp - last) / 1000, 0.1);
this.lastTimestamp = timestamp;
this.tick(deltaTime);
this.rafId = requestAnimationFrame(loop);
};
this.rafId = requestAnimationFrame(loop);
}
stop() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.lastTimestamp = null;
}
private tick(deltaTime: number) {
this.updateMovementAndChopping(deltaTime);
this.updateLogFlights(deltaTime);
this.updatePickups();
this.updateCoinConversion(deltaTime);
this.updateAutosave(deltaTime);
}
private updateAutosave(deltaTime: number) {
this.saveProgress += deltaTime;
if (this.saveProgress < AUTOSAVE_INTERVAL)
return;
this.saveProgress = 0;
this.save();
}
private updateMovementAndChopping(deltaTime: number) {
const tree = this.heldTree;
if (tree) {
this.walkTowardTree(tree, deltaTime);
return;
}
if (this.moveTarget) {
this.walkTowardPoint(this.moveTarget, deltaTime);
return;
}
this.chopProgress = 0;
}
private walkTowardPoint(target: Vec2, deltaTime: number) {
const dx = target.x - this.character.x;
const dy = target.y - this.character.y;
const distance = Math.hypot(dx, dy);
if (distance <= 0.5) {
this.character = { x: target.x, y: target.y };
return;
}
const step = Math.min(this.characterSpeed * deltaTime, distance);
const ratio = step / distance;
this.character = {
x: this.character.x + dx * ratio,
y: this.character.y + dy * ratio,
};
}
private walkTowardTree(tree: TreeState, deltaTime: number) {
const dx = tree.x - this.character.x;
const dy = tree.y - this.character.y;
const distance = Math.hypot(dx, dy);
// A small tolerance keeps the character from flip-flopping between "walking" and "chopping"
// every frame once it's essentially at the interaction radius, which floating-point error on
// the two-axis step below can otherwise cause (each flip resets chopProgress, so chopping
// could stall forever right at the boundary).
if (distance > INTERACTION_RADIUS + 0.5) {
const step = Math.min(this.characterSpeed * deltaTime, distance - INTERACTION_RADIUS);
const ratio = step / distance;
this.character = {
x: this.character.x + dx * ratio,
y: this.character.y + dy * ratio,
};
this.chopProgress = 0;
return;
}
const chopInterval = this.chopInterval;
this.chopProgress += deltaTime;
while (this.chopProgress >= chopInterval && tree.logsRemaining > 0) {
this.chopProgress -= chopInterval;
tree.logsRemaining -= 1;
// Eject the log on a ballistic arc to a random spot within a circle around the tree; it
// only becomes pickable once it lands (see updateLogFlights/updatePickups).
const angle = Math.random() * Math.PI * 2;
const landDistance = Math.random() * LOG_EJECT_RADIUS;
const toX = tree.x + Math.cos(angle) * landDistance;
const toY = tree.y + Math.sin(angle) * landDistance;
this.logDrops.push({
id: randomId('log'),
x: tree.x,
y: tree.y,
z: 0,
phase: 'falling',
flight: {
fromX: tree.x,
fromY: tree.y,
toX,
toY,
elapsed: 0,
duration: LOG_FALL_DURATION,
arcHeight: LOG_FALL_ARC_HEIGHT,
},
});
if (tree.logsRemaining <= 0) {
this.trees = this.trees.filter((t) => t.id !== tree.id);
this.heldTreeId = null;
this.chopProgress = 0;
break;
}
}
}
/** Advances every log currently in a ballistic flight (falling from a tree, or flying into the
* inventory once collected), landing/collecting it once its flight completes. Mutates logs in
* place and only reassigns the `logDrops` array when one is actually removed (collected) — a
* landed log sitting idle, or the array being rebuilt every tick regardless, would otherwise
* force every observer of `logDrops` (i.e. the whole map) to re-render every animation frame. */
private updateLogFlights(deltaTime: number) {
if (this.logDrops.length === 0)
return;
let collectedIds: Set<string> | null = null;
for (const log of this.logDrops) {
if (log.phase === 'landed')
continue;
const flight = log.flight!;
flight.elapsed += deltaTime;
const t = Math.min(1, flight.elapsed / flight.duration);
log.x = flight.fromX + (flight.toX - flight.fromX) * t;
log.y = flight.fromY + (flight.toY - flight.fromY) * t;
log.z = flight.arcHeight * 4 * t * (1 - t);
if (t < 1)
continue;
if (log.phase === 'falling') {
log.phase = 'landed';
log.flight = undefined;
log.z = 0;
} else {
// Collecting flight finished: the log has reached the character.
(collectedIds ??= new Set()).add(log.id);
this.inventory.logs += 1;
}
}
if (collectedIds)
this.logDrops = this.logDrops.filter((log) => !collectedIds!.has(log.id));
}
/** Starts a landed log flying into the inventory once the character gets close enough. */
private updatePickups() {
const pickupRadius = this.pickupRadiusPx;
for (const log of this.logDrops) {
if (log.phase !== 'landed')
continue;
const distance = Math.hypot(log.x - this.character.x, log.y - this.character.y);
if (distance > pickupRadius)
continue;
log.phase = 'collecting';
log.flight = {
fromX: log.x,
fromY: log.y,
toX: this.character.x,
toY: this.character.y,
elapsed: 0,
duration: LOG_COLLECT_DURATION,
arcHeight: LOG_COLLECT_ARC_HEIGHT,
};
}
}
private updateCoinConversion(deltaTime: number) {
if (this.inventory.logs <= 0) {
this.coinProgress = 0;
return;
}
const coinsPerLog = this.coinsPerLog;
const conversionInterval = this.coinConversionInterval;
this.coinProgress += deltaTime;
while (this.coinProgress >= conversionInterval && this.inventory.logs > 0) {
this.coinProgress -= conversionInterval;
this.inventory.logs -= 1;
this.inventory.coins += coinsPerLog;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
export const TREE_COUNT = 5;
export const LOGS_PER_TREE = 5;
/** How close the character must get to a tree's center to start chopping it. */
export const INTERACTION_RADIUS = 56;
/** Radius of the circle around a tree that ejected logs can land in. */
export const LOG_EJECT_RADIUS = 70;
/** Seconds for a log's ballistic flight from the tree to its landing spot. */
export const LOG_FALL_DURATION = 0.5;
/** Peak height (px) of a log's arc while falling from the tree. */
export const LOG_FALL_ARC_HEIGHT = 40;
/** Seconds for a landed log's flight into the character's inventory. */
export const LOG_COLLECT_DURATION = 0.25;
/** Peak height (px) of a log's little hop while flying into the inventory. */
export const LOG_COLLECT_ARC_HEIGHT = 16;
/** Trees won't spawn closer than this to the character's starting position. */
export const TREE_MIN_DISTANCE_FROM_CENTER = 140;
/** Trees won't spawn closer than this to each other. */
export const TREE_MIN_DISTANCE_BETWEEN = 100;
/** Trees won't spawn within this many px of the map edge. */
export const TREE_EDGE_MARGIN = 70;
+51
View File
@@ -0,0 +1,51 @@
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
import type { UpgradeId } from './upgrades';
const STORAGE_KEY = 'forest-save-v1';
export interface SaveData {
version: 1;
inventory: Inventory;
upgradeLevels: Record<UpgradeId, number>;
/** World state (character/trees/logs) is optional so older saves without it still load fine —
* progress is restored either way, just with a freshly spawned world. */
character?: Vec2;
trees?: TreeState[];
logDrops?: LogDrop[];
}
function isValidSave(value: unknown): value is SaveData {
if (!value || typeof value !== 'object')
return false;
const data = value as Partial<SaveData>;
return (
data.version === 1
&& typeof data.inventory === 'object' && data.inventory !== null
&& typeof data.inventory.logs === 'number'
&& typeof data.inventory.coins === 'number'
&& typeof data.upgradeLevels === 'object' && data.upgradeLevels !== null
);
}
/** Reads the saved game from localStorage, or null if there's none / it's corrupted. */
export function loadSave(): SaveData | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw)
return null;
const parsed: unknown = JSON.parse(raw);
return isValidSave(parsed) ? parsed : null;
} catch {
// localStorage unavailable (private mode, disabled) or corrupted JSON — treat as no save.
return null;
}
}
/** Writes the save to localStorage. Best-effort: silently no-ops if storage is unavailable/full. */
export function writeSave(data: SaveData): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// Ignore — e.g. private browsing mode or quota exceeded.
}
}
+135
View File
@@ -0,0 +1,135 @@
/** Pixel grids for 8-bit-style sprites. Each cell is a CSS color, or '' for transparent. */
export type PixelGrid = string[][];
function grid(rows: string[], palette: Record<string, string>): PixelGrid {
return rows.map((row) => [...row].map((ch) => palette[ch] ?? ''));
}
const CHARACTER_PALETTE = {
O: '#1b1611',
H: '#4a2f1c',
S: '#f0c090',
s: '#d9a873',
B: '#3f6fb0',
b: '#2f5488',
P: '#2c2c3e',
p: '#20202e',
F: '#2a1f14',
};
export const CHARACTER_SPRITE: PixelGrid = grid(
[
'....OOOOOOOO....',
'..OHHHHHHHHHHO..',
'.OHHHHHHHHHHHHO.',
'OHHHHHHHHHHHHHHO',
'OHHSSSSSSSSSSHHO',
'OSSSSOSSSSOSSSSO',
'OSSSSSSssSSSSSSO',
'.OSSSSSSSSSSSSO.',
'......OSSO......',
'...OBBBBBBBBO...',
'..OBBBBBBBBBBO..',
'.OSBBBBBBBBBBSO.',
'.OSBBbBBBBbBBSO.',
'.OSBBbBBBBbBBSO.',
'..OBBbBBBBbBBO..',
'..OPPpPPPPpPPO..',
'..OPPpPPPPpPPO..',
'..OPPpO..OpPPO..',
'..OPPpO..OpPPO..',
'..OPPpO..OpPPO..',
'..OFFFO..OFFFO..',
'.OFFFFO..OFFFFO.',
],
CHARACTER_PALETTE,
);
const TREE_PALETTE = {
O: '#14290f',
G: '#2e7d32',
g: '#1b5e20',
H: '#66bb6a',
T: '#6b4226',
t: '#4e2f1a',
n: '#8a5a34',
};
export const TREE_SPRITE: PixelGrid = grid(
[
'......OggO......',
'....OGGggGGO....',
'..OGGGGggGGGGO..',
'.OGgGGGHHGGGgGO.',
'OGgGGGGHHGGGGgGO',
'OGGgGGGggGGGgGGO',
'OGgGGGgGGgGGGgGO',
'OGGgGGGGGGGGgGGO',
'OGgGGGGggGGGGgGO',
'.OGgGGGggGGGgGO.',
'..OGgGGggGGgGO..',
'...OGgGggGgGO...',
'....OGgGGgGO....',
'......OggO......',
'......OTTO......',
'......TTTT......',
'......TttT......',
'......tnnt......',
'......TTTT......',
'......TOOT......',
],
TREE_PALETTE,
);
const LOG_PALETTE = {
O: '#2a1a0d',
B: '#7a4a2b',
C: '#a9703f',
};
export const LOG_SPRITE: PixelGrid = grid(
[
'..OOOOOOOOOOOOOOOO..',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'..OOOOOOOOOOOOOOOO..',
],
LOG_PALETTE,
);
const COIN_PALETTE = {
Y: '#f5c518',
D: '#c99a0e',
W: '#fff2b0',
E: '#a97c08',
};
export const COIN_SPRITE: PixelGrid = grid(
[
'.....YYYYYY.....',
'...YYYYYYYYYY...',
'..YWWYYYYYYYYY..',
'.YYWWYYYYYYYYYY.',
'.YYYYYYYYYYYYYY.',
'YYYYYDDDDDDYYYYY',
'YYYYYDYYYYDYYYYY',
'YYYYYDYYYYDYYYYY',
'YYYYYDYYYYDYYYYY',
'YYYYYDYYYYDYYYYY',
'YYYYYDDDDDDYYYYY',
'.YYYYYYYYYYYYYY.',
'.YYYYYYYYYYEEYY.',
'..YYYYYYYYYEEY..',
'...YYYYYYYYYY...',
'.....YYYYYY.....',
],
COIN_PALETTE,
);
+38
View File
@@ -0,0 +1,38 @@
export interface Vec2 {
x: number;
y: number;
}
export interface TreeState {
id: string;
x: number;
y: number;
logsRemaining: number;
}
export type LogPhase = 'falling' | 'landed' | 'collecting';
export interface LogFlight {
fromX: number;
fromY: number;
toX: number;
toY: number;
elapsed: number;
duration: number;
arcHeight: number;
}
export interface LogDrop {
id: string;
x: number;
y: number;
/** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */
z: number;
phase: LogPhase;
flight?: LogFlight;
}
export interface Inventory {
logs: number;
coins: number;
}
+63
View File
@@ -0,0 +1,63 @@
export type UpgradeId = 'moveSpeed' | 'chopSpeed' | 'logValue' | 'pickupRadius' | 'coinSpeed';
export interface UpgradeDef {
id: UpgradeId;
name: string;
baseCost: number;
costGrowth: number;
/** The upgrade's stat value at a given level (levels start at 1, unbounded above). */
effectAtLevel: (level: number) => number;
formatEffect: (value: number) => string;
}
export const UPGRADE_LIST: UpgradeDef[] = [
{
id: 'moveSpeed',
name: 'Скорость движения',
baseCost: 5,
costGrowth: 1.35,
effectAtLevel: (level) => 160 + (level - 1) * 20,
formatEffect: (v) => `${Math.round(v)} px/с`,
},
{
id: 'chopSpeed',
name: 'Скорость рубки',
baseCost: 6,
costGrowth: 1.35,
effectAtLevel: (level) => (1 / 0.6) * Math.pow(1.15, level - 1),
formatEffect: (v) => `${v.toFixed(2)} бревен/с`,
},
{
id: 'logValue',
name: 'Стоимость бревна',
baseCost: 8,
costGrowth: 1.4,
effectAtLevel: (level) => level,
formatEffect: (v) => `${v} монет/бревно`,
},
{
id: 'pickupRadius',
name: 'Радиус подбора',
baseCost: 5,
costGrowth: 1.3,
effectAtLevel: (level) => 46 + (level - 1) * 6,
formatEffect: (v) => `${Math.round(v)} px`,
},
{
id: 'coinSpeed',
name: 'Скорость обмена на монеты',
baseCost: 7,
costGrowth: 1.35,
effectAtLevel: (level) => Math.pow(1.15, level - 1),
formatEffect: (v) => `${v.toFixed(2)} бревен/с`,
},
];
export const UPGRADES: Record<UpgradeId, UpgradeDef> = Object.fromEntries(
UPGRADE_LIST.map((def) => [def.id, def]),
) as Record<UpgradeId, UpgradeDef>;
/** Cost (in coins) to go from `currentLevel` to `currentLevel + 1`. */
export function costForNextLevel(def: UpgradeDef, currentLevel: number): number {
return Math.ceil(def.baseCost * Math.pow(def.costGrowth, currentLevel - 1));
}