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