diff --git a/index.html b/index.html index 8915e34..4a972d5 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,10 @@ - + forest diff --git a/src/App.css b/src/App.css index c878bf8..e5fc43a 100644 --- a/src/App.css +++ b/src/App.css @@ -155,6 +155,15 @@ z-index: 0; } +.entity.twig-shadow { + width: 18px; + height: 9px; + margin: 7px 0px 0px 0px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + .entity.tree-shadow { width: 64px; height: 32px; @@ -164,6 +173,15 @@ z-index: 0; } +.entity.bush-shadow { + width: 40px; + height: 20px; + border-radius: 50%; + margin: 16px 0px 0px -6px; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + .entity.character-shadow { width: 48px; height: 24px; @@ -180,7 +198,14 @@ margin: -12px; } -.entity.tree.tree--chopping svg { +.entity.bush { + pointer-events: auto; + cursor: pointer; + padding: 8px; + margin: -8px; +} + +.entity.chopping svg { animation: chop-shake 0.12s infinite; } diff --git a/src/components/GameMap.tsx b/src/components/GameMap.tsx index 5048de4..1742a9f 100644 --- a/src/components/GameMap.tsx +++ b/src/components/GameMap.tsx @@ -2,41 +2,57 @@ import { observer } from 'mobx-react-lite'; import { useEffect, useRef, useState } from 'react'; import type { PointerEvent } from 'react'; import type { GameStore } from '../game/GameStore'; -import type { LogDrop, TreeState } from '../game/types'; -import { CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE } from '../game/sprites'; +import type { ResourceDrop, ResourceNode } from '../game/types'; +import { BUSH_SPRITE, CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE, TWIG_SPRITE } from '../game/sprites'; import PixelSprite from './PixelSprite'; interface Props { store: GameStore; } +// Must mirror the @media rule in index.css that rotates #root into landscape on a portrait +// touchscreen — when that rule is active, the browser paints everything rotated 90°, but +// PointerEvent.clientX/clientY (and Element.getBoundingClientRect()) keep reporting the +// physical, unrotated screen: physical x/y and physical width/height end up swapped relative to +// the app's own (rotated) layout, and have to be converted back explicitly wherever they're used. +const ROTATE_QUERY = '(orientation: portrait) and (pointer: coarse)'; + // Each entity below is its own observer so mobx can subscribe it only to the fields it actually // reads. Otherwise, with everything read inside GameMap's single observer, the character moving -// every animation frame would re-render the whole tree/log list too, not just itself. +// every animation frame would re-render the whole node/drop list too, not just itself. -const LogEntity = observer(function LogEntity({ log }: { log: LogDrop }) { +const DROP_SPRITES = { log: LOG_SPRITE, twig: TWIG_SPRITE }; +const DROP_SIZES = { log: 20, twig: 18 }; + +const ResourceDropEntity = observer(function ResourceDropEntity({ drop }: { drop: ResourceDrop }) { return (
-
-
- +
+
+
); }); -const TreeEntity = observer(function TreeEntity({ tree, store }: { tree: TreeState; store: GameStore }) { - const isChopping = tree.id === store.heldTreeId && store.isChopping; +const NODE_SPRITES = { tree: TREE_SPRITE, bush: BUSH_SPRITE }; +const NODE_SIZES = { tree: 64, bush: 40 }; + +const NodeEntity = observer(function NodeEntity({ node, store }: { node: ResourceNode; store: GameStore }) { + const isChopping = node.id === store.heldNodeId && store.isChopping; return (
-
+
- +
); @@ -62,13 +78,27 @@ const CharacterEntity = observer(function CharacterEntity({ store }: { store: Ga function GameMap({ store }: Props) { const containerRef = useRef(null); const [ready, setReady] = useState(false); + const isRotatedRef = useRef(false); + + useEffect(() => { + const mql = window.matchMedia(ROTATE_QUERY); + isRotatedRef.current = mql.matches; + const handleChange = (e: MediaQueryListEvent) => { + isRotatedRef.current = e.matches; + }; + mql.addEventListener('change', handleChange); + return () => mql.removeEventListener('change', handleChange); + }, []); useEffect(() => { const container = containerRef.current; if (!container) return; - const { width, height } = container.getBoundingClientRect(); + // getBoundingClientRect() reports the physical (rotated) box, whose width/height are swapped + // relative to the app's own logical layout — see ROTATE_QUERY above. + const rect = container.getBoundingClientRect(); + const [width, height] = isRotatedRef.current ? [rect.height, rect.width] : [rect.width, rect.height]; if (width > 0 && height > 0) { store.initialize(width, height); store.start(); @@ -90,31 +120,45 @@ function GameMap({ store }: Props) { }; }, [store]); - /** Finds the tree (if any) the pointer is currently over, by DOM hit-testing at its client - * coordinates. */ - function treeIdAtPoint(clientX: number, clientY: number): string | null { - const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-tree-id]'); - return hit?.getAttribute('data-tree-id') ?? null; + /** Finds the node (if any) the pointer is currently over, by DOM hit-testing at its client + * coordinates. elementFromPoint uses physical screen coordinates and already accounts for any + * CSS transform, so this needs no rotation correction (unlike toLocalPoint below). */ + function nodeIdAtPoint(clientX: number, clientY: number): string | null { + const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-node-id]'); + return hit?.getAttribute('data-node-id') ?? null; + } + + /** Converts a pointer event's physical screen position into the map's own logical (x, y) — + * i.e. what the game world uses for the character's target position. When the map is rotated + * (see ROTATE_QUERY), physical x/y are swapped and one is mirrored relative to logical x/y, so + * a plain subtraction of rect.left/rect.top would point the character the wrong way. */ + function toLocalPoint(e: PointerEvent, rect: DOMRect): { x: number; y: number } { + const physicalX = e.clientX - rect.left; + const physicalY = e.clientY - rect.top; + if (isRotatedRef.current) + return { x: physicalY, y: rect.width - physicalX }; + return { x: physicalX, y: physicalY }; } /** The character's action is purely a function of the current pointer state: if the primary * button isn't held, nothing happens; otherwise it heads for whatever's under the cursor right - * now — a tree to chop, or open ground to walk to. Shared by pointerdown/move/up so panning the - * cursor across a tree mid-drag starts/stops chopping it, and releasing the button stops it. */ + * now — a node to chop, or open ground to walk to. Shared by pointerdown/move/up so panning the + * cursor across a node mid-drag starts/stops chopping it, and releasing the button stops it. */ function followPointer(e: PointerEvent) { if ((e.buttons & 1) === 0) { store.release(); return; } - const treeId = treeIdAtPoint(e.clientX, e.clientY); - if (treeId) { - store.holdTree(treeId); + const nodeId = nodeIdAtPoint(e.clientX, e.clientY); + if (nodeId) { + store.holdNode(nodeId); return; } const rect = e.currentTarget.getBoundingClientRect(); - store.moveTo(e.clientX - rect.left, e.clientY - rect.top); + const { x, y } = toLocalPoint(e, rect); + store.moveTo(x, y); } function handlePointerDown(e: PointerEvent) { @@ -133,12 +177,12 @@ function GameMap({ store }: Props) { > {ready && ( <> - {store.logDrops.map((log) => ( - + {store.resourceDrops.map((drop) => ( + ))} - {store.trees.map((tree) => ( - + {store.nodes.map((node) => ( + ))} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index e22b25b..5d01c7b 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -1,6 +1,6 @@ import { observer } from 'mobx-react-lite'; import type { GameStore } from '../game/GameStore'; -import { COIN_SPRITE, LOG_SPRITE } from '../game/sprites'; +import { COIN_SPRITE, LOG_SPRITE, TWIG_SPRITE } from '../game/sprites'; import PixelSprite from './PixelSprite'; interface Props { @@ -15,13 +15,17 @@ function Topbar({ store }: Props) { Уровень {store.level}
+
+ + {store.inventory.twigs} +
{store.inventory.logs}
- {store.inventory.coins} + {store.inventory.coins.toFixed(1)}
); diff --git a/src/game/GameStore.ts b/src/game/GameStore.ts index 7af2cb6..ffe9fd3 100644 --- a/src/game/GameStore.ts +++ b/src/game/GameStore.ts @@ -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 = { 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 | 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; + } } } } diff --git a/src/game/constants.ts b/src/game/constants.ts index ddb0223..f483152 100644 --- a/src/game/constants.ts +++ b/src/game/constants.ts @@ -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; diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 5d22d45..c7551bd 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -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; - /** 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; 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); diff --git a/src/game/sprites.ts b/src/game/sprites.ts index 2c045ee..eba9f6d 100644 --- a/src/game/sprites.ts +++ b/src/game/sprites.ts @@ -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', diff --git a/src/game/types.ts b/src/game/types.ts index eee8eb0..546bbae 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -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; } -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; } diff --git a/src/game/upgrades.ts b/src/game/upgrades.ts index 5eeca68..7a95829 100644 --- a/src/game/upgrades.ts +++ b/src/game/upgrades.ts @@ -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)} с/ресурс`, }, ]; diff --git a/src/index.css b/src/index.css index aef8a2a..b80dc94 100644 --- a/src/index.css +++ b/src/index.css @@ -18,8 +18,28 @@ body { height: 100%; margin: 0; overflow: hidden; + overscroll-behavior: none; + touch-action: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; } #root { height: 100%; } + +/* This is a fixed-landscape game. On a touchscreen held in portrait, rotate the whole app into + * landscape instead of showing a cramped/wrong-aspect portrait layout — screen.orientation.lock + * (see main.tsx) covers the cases it can, this CSS covers everywhere else. */ +@media screen and (orientation: portrait) and (pointer: coarse) { + #root { + position: fixed; + top: 0; + left: 0; + width: 100vh; + height: 100vw; + transform-origin: top left; + transform: rotate(90deg) translate(0, -100%); + } +} diff --git a/src/main.tsx b/src/main.tsx index bef5202..4eb3897 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +// Best-effort: the browser only grants this in contexts like fullscreen or an installed PWA, and +// throws everywhere else — the CSS rotate fallback in index.css covers the rest. +screen.orientation?.lock?.('landscape').catch(() => {}) + createRoot(document.getElementById('root')!).render(