From 325aa8915dfd6c0f605cbcf88d1d65bf73e3acab Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Fri, 4 Sep 2026 15:21:02 +0300 Subject: [PATCH] forest: map with trees --- forest/index.html | 6 + forest/package-lock.json | 26 +++ forest/package.json | 2 + forest/src/App.css | 284 ++++++++++------------- forest/src/App.tsx | 131 +---------- forest/src/components/GameMap.tsx | 155 +++++++++++++ forest/src/components/PixelSprite.tsx | 35 +++ forest/src/components/Topbar.tsx | 27 +++ forest/src/game/GameStore.ts | 317 ++++++++++++++++++++++++++ forest/src/game/constants.ts | 41 ++++ forest/src/game/sprites.ts | 135 +++++++++++ forest/src/game/types.ts | 38 +++ forest/src/index.css | 110 +-------- forest/vite.config.ts | 1 + 14 files changed, 922 insertions(+), 386 deletions(-) create mode 100644 forest/src/components/GameMap.tsx create mode 100644 forest/src/components/PixelSprite.tsx create mode 100644 forest/src/components/Topbar.tsx create mode 100644 forest/src/game/GameStore.ts create mode 100644 forest/src/game/constants.ts create mode 100644 forest/src/game/sprites.ts create mode 100644 forest/src/game/types.ts diff --git a/forest/index.html b/forest/index.html index 27b851a..8915e34 100644 --- a/forest/index.html +++ b/forest/index.html @@ -5,6 +5,12 @@ forest + + +
diff --git a/forest/package-lock.json b/forest/package-lock.json index 698a24c..ee0cacf 100644 --- a/forest/package-lock.json +++ b/forest/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@incgame/engine": "file:../engine", "break_eternity.js": "^2.1.3", + "mobx": "^7.0.3", + "mobx-react-lite": "^5.0.3", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -1010,6 +1012,30 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/mobx": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-7.0.3.tgz", + "integrity": "sha512-KopyyzqZ8xT1fqxFNl8ItKKMpGifRe2ySaNAHs80/cx0mL1bpJtJ46S+FSyzXKRjW0TODWj5zwq/Y9hUdJUy4w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, + "node_modules/mobx-react-lite": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-5.0.3.tgz", + "integrity": "sha512-8qT/d/8Cg8ljt+heKhoFHNoqgedyeSGzdTixT0lgbFMMFlWfHUcXHZ8UD63eQi3ELjkxaH1VU07lXWhRwM0cVA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^7.0.0", + "react": "^18 || ^19" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", diff --git a/forest/package.json b/forest/package.json index 8089beb..3f2dd09 100644 --- a/forest/package.json +++ b/forest/package.json @@ -12,6 +12,8 @@ "dependencies": { "@incgame/engine": "file:../engine", "break_eternity.js": "^2.1.3", + "mobx": "^7.0.3", + "mobx-react-lite": "^5.0.3", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/forest/src/App.css b/forest/src/App.css index f90339d..a6e84ec 100644 --- a/forest/src/App.css +++ b/forest/src/App.css @@ -1,184 +1,130 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { +.app { display: flex; flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; + height: 100%; +} + +.topbar { + display: flex; + align-items: center; + gap: 20px; + padding: 10px 16px; + background: var(--panel-bg); + border-bottom: 4px solid var(--panel-border); + box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.2); + flex-shrink: 0; +} + +.topbar__title { + font-size: 14px; + margin: 0; + color: var(--accent); + text-shadow: 2px 2px 0 var(--panel-border); +} + +.topbar .spacer { flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; +.inventory-slot { display: flex; + align-items: center; gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } + padding: 6px 12px; + background: rgba(0, 0, 0, 0.25); + border: 3px solid var(--panel-border); + font-size: 14px; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { +.game-map { position: relative; - width: 100%; + flex-grow: 1; + overflow: hidden; + background-color: #4caf50; + background-image: + linear-gradient(45deg, #46a047 25%, transparent 25%), + linear-gradient(-45deg, #46a047 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #46a047 75%), + linear-gradient(-45deg, transparent 75%, #46a047 75%); + background-size: 32px 32px; + background-position: 0 0, 0 16px, 16px -16px, -16px 0px; + cursor: default; + touch-action: none; + user-select: none; +} - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } +.entity { + position: absolute; + transform: translate(-50%, -50%); + pointer-events: none; +} - &::before { - left: 0; - border-left-color: var(--border); +.entity.log-shadow { + width: 20px; + height: 10px; + margin: 8px 0px 0px 0px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + +.entity.tree-shadow { + width: 64px; + height: 32px; + border-radius: 50%; + margin: 26px 0px 0px -11px; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + +.entity.character-shadow { + width: 48px; + height: 24px; + border-radius: 50%; + margin: 32px 0px 0px 0px; + background: rgba(0, 0, 0, 0.35); + z-index: 0; +} + +.entity.tree { + pointer-events: auto; + cursor: pointer; + padding: 12px; + margin: -12px; +} + +.entity.tree.tree--chopping svg { + animation: chop-shake 0.12s infinite; +} + +.entity.character { + transition: none; +} + +.entity.character.character--walking svg { + animation: walk-bob 0.3s infinite steps(2); +} + +@keyframes chop-shake { + 0%, 100% { + transform: translate(0, 0) rotate(0deg); } - &::after { - right: 0; - border-right-color: var(--border); + 25% { + transform: translate(-2px, 1px) rotate(-6deg); + } + 50% { + transform: translate(1px, -2px) rotate(5deg); + } + 75% { + transform: translate(-1px, 1px) rotate(-4deg); + } +} + +@keyframes walk-bob { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(-3px); } } diff --git a/forest/src/App.tsx b/forest/src/App.tsx index 4f6915b..8003a81 100644 --- a/forest/src/App.tsx +++ b/forest/src/App.tsx @@ -1,125 +1,18 @@ -import { useState } from 'react' -import { deserializeGame, type Game } from '@incgame/engine' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' +import { useState } from 'react'; +import GameMap from './components/GameMap'; +import Topbar from './components/Topbar'; +import { GameStore } from './game/GameStore'; +import './App.css'; function App() { - const [game] = useState(() => deserializeGame({ time: 0, nodes: [] })) - const [time, setTime] = useState(() => game.time.toString()) - - function handleTick() { - game.tick(0.1) - setTime(game.time.toString()) - } + const [store] = useState(() => new GameStore()); return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +
+ + +
+ ); } -export default App +export default App; diff --git a/forest/src/components/GameMap.tsx b/forest/src/components/GameMap.tsx new file mode 100644 index 0000000..79ac3ec --- /dev/null +++ b/forest/src/components/GameMap.tsx @@ -0,0 +1,155 @@ +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 PixelSprite from './PixelSprite'; + +interface Props { + store: GameStore; +} + +// 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. + +const LogEntity = observer(function LogEntity({ log }: { log: LogDrop }) { + return ( +
+
+
+ +
+
+ ); +}); + +const TreeEntity = observer(function TreeEntity({ + tree, + store, + onPointerDown, + onPointerUp, +}: { + tree: TreeState; + store: GameStore; + onPointerDown: (e: PointerEvent, id: string) => void; + onPointerUp: (e: PointerEvent) => void; +}) { + const isChopping = tree.id === store.heldTreeId && store.isChopping; + + return ( +
+
+
onPointerDown(e, tree.id)} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + > + +
+
+ ); +}); + +const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) { + return ( + <> +
+
+ +
+ + ); +}); + +function GameMap({ store }: Props) { + const containerRef = useRef(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + const container = containerRef.current; + if (!container) + return; + + const { width, height } = container.getBoundingClientRect(); + if (width > 0 && height > 0) { + store.initialize(width, height); + store.start(); + setReady(true); + } + + return () => { + store.stop(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + window.addEventListener('blur', store.release); + return () => window.removeEventListener('blur', store.release); + }, [store]); + + function handleTreePointerDown(e: PointerEvent, id: string) { + if (e.button !== 0) + return; + // Stop this from also bubbling to the map's own pointer handlers below (which would + // otherwise interpret the same press as "walk to this empty ground point"). + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + store.holdTree(id); + } + + function handleTreePointerUp(e: PointerEvent) { + e.stopPropagation(); + store.release(); + } + + function handleMapPointerDown(e: PointerEvent) { + if (e.button !== 0) + return; + const rect = e.currentTarget.getBoundingClientRect(); + e.currentTarget.setPointerCapture(e.pointerId); + store.moveTo(e.clientX - rect.left, e.clientY - rect.top); + } + + return ( +
+ {ready && ( + <> + {store.logDrops.map((log) => ( + + ))} + + {store.trees.map((tree) => ( + + ))} + + + + )} +
+ ); +} + +export default observer(GameMap); diff --git a/forest/src/components/PixelSprite.tsx b/forest/src/components/PixelSprite.tsx new file mode 100644 index 0000000..f82c418 --- /dev/null +++ b/forest/src/components/PixelSprite.tsx @@ -0,0 +1,35 @@ +import { memo } from 'react'; +import type { PixelGrid } from '../game/sprites'; + +interface Props { + pixels: PixelGrid; + size: number; + className?: string; +} + +function PixelSprite({ pixels, size, className }: Props) { + const rows = pixels.length; + const cols = pixels[0]?.length ?? 0; + + return ( + 0 ? size * (rows / cols) : size} + viewBox={`0 0 ${cols} ${rows}`} + shapeRendering="crispEdges" + > + {pixels.flatMap((row, y) => + row.map((color, x) => + color ? : null, + ), + )} + + ); +} + +// `pixels` is always one of the module-level sprite constants and `size` is a literal passed by +// the caller, so both are reference/value-stable across re-renders — memoizing means an entity +// re-rendering every frame for its position (left/top) doesn't also regenerate this sprite's +// (potentially hundreds of) elements each time. +export default memo(PixelSprite); diff --git a/forest/src/components/Topbar.tsx b/forest/src/components/Topbar.tsx new file mode 100644 index 0000000..b8a476f --- /dev/null +++ b/forest/src/components/Topbar.tsx @@ -0,0 +1,27 @@ +import { observer } from 'mobx-react-lite'; +import type { GameStore } from '../game/GameStore'; +import { COIN_SPRITE, LOG_SPRITE } from '../game/sprites'; +import PixelSprite from './PixelSprite'; + +interface Props { + store: GameStore; +} + +function Topbar({ store }: Props) { + return ( +
+

Forest

+
+
+ + {store.inventory.logs} +
+
+ + {store.inventory.coins} +
+
+ ); +} + +export default observer(Topbar); diff --git a/forest/src/game/GameStore.ts b/forest/src/game/GameStore.ts new file mode 100644 index 0000000..197b48b --- /dev/null +++ b/forest/src/game/GameStore.ts @@ -0,0 +1,317 @@ +import { makeAutoObservable } from 'mobx'; +import type { Inventory, LogDrop, TreeState, Vec2 } from './types'; +import { + CHARACTER_SPEED, + CHOP_INTERVAL, + COIN_INTERVAL, + INTERACTION_RADIUS, + LOG_COLLECT_ARC_HEIGHT, + LOG_COLLECT_DURATION, + LOG_EJECT_RADIUS, + LOG_FALL_ARC_HEIGHT, + LOG_FALL_DURATION, + LOGS_PER_TREE, + PICKUP_RADIUS, + TREE_COUNT, + TREE_EDGE_MARGIN, + TREE_MIN_DISTANCE_BETWEEN, + TREE_MIN_DISTANCE_FROM_CENTER, +} from './constants'; + +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; + + private chopProgress = 0; + private coinProgress = 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)`). + 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; + } + + /** (Re)initializes the game for a map of the given size, placing the character in the center and scattering trees. */ + initialize(width: number, height: number) { + this.character = { x: width / 2, y: height / 2 }; + this.trees = this.spawnTrees(width, height); + this.logDrops = []; + this.inventory = { logs: 0, coins: 0 }; + this.heldTreeId = null; + this.moveTarget = null; + this.chopProgress = 0; + this.coinProgress = 0; + } + + 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); + } + + 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(CHARACTER_SPEED * 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(CHARACTER_SPEED * 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; + } + + this.chopProgress += deltaTime; + while (this.chopProgress >= CHOP_INTERVAL && tree.logsRemaining > 0) { + this.chopProgress -= CHOP_INTERVAL; + 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 | 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() { + 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 > PICKUP_RADIUS) + 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; + } + + this.coinProgress += deltaTime; + while (this.coinProgress >= COIN_INTERVAL && this.inventory.logs > 0) { + this.coinProgress -= COIN_INTERVAL; + this.inventory.logs -= 1; + this.inventory.coins += 1; + } + } +} diff --git a/forest/src/game/constants.ts b/forest/src/game/constants.ts new file mode 100644 index 0000000..37a9784 --- /dev/null +++ b/forest/src/game/constants.ts @@ -0,0 +1,41 @@ +export const TREE_COUNT = 5; +export const LOGS_PER_TREE = 5; + +/** Character movement speed, px/s. */ +export const CHARACTER_SPEED = 160; + +/** How close the character must get to a tree's center to start chopping it. */ +export const INTERACTION_RADIUS = 56; + +/** How close a landed log must be to the character to start flying into the inventory. */ +export const PICKUP_RADIUS = 46; + +/** 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; + +/** Seconds between logs dropping while a tree is held and in range. */ +export const CHOP_INTERVAL = 0.6; + +/** Seconds for one log in the inventory to turn into one coin. */ +export const COIN_INTERVAL = 1; + +/** 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; diff --git a/forest/src/game/sprites.ts b/forest/src/game/sprites.ts new file mode 100644 index 0000000..2c045ee --- /dev/null +++ b/forest/src/game/sprites.ts @@ -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): 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, +); diff --git a/forest/src/game/types.ts b/forest/src/game/types.ts new file mode 100644 index 0000000..eee8eb0 --- /dev/null +++ b/forest/src/game/types.ts @@ -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; +} diff --git a/forest/src/index.css b/forest/src/index.css index 5fb3313..aef8a2a 100644 --- a/forest/src/index.css +++ b/forest/src/index.css @@ -1,111 +1,25 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --panel-bg: #3a2a1e; + --panel-border: #1b1611; + --text: #f5e9d0; + --accent: #f5c518; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; + font: 16px/1.4 'Press Start 2P', ui-monospace, Consolas, monospace; color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } + color-scheme: dark; } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +* { box-sizing: border-box; } +html, body { + height: 100%; margin: 0; + overflow: hidden; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +#root { + height: 100%; } diff --git a/forest/vite.config.ts b/forest/vite.config.ts index 4befa34..9104d77 100644 --- a/forest/vite.config.ts +++ b/forest/vite.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ exclude: ['@incgame/engine'], }, server: { + host: '0.0.0.0', fs: { // The engine package lives outside this project's root (a sibling directory reached // through a node_modules symlink); allow Vite to serve/watch files from there too.