ветки и кусты

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