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 (
- <>
-
-
-
-
Get started
-
- Edit src/App.tsx and save to test HMR
-
-
-
- @incgame/engine time: {time}
-
-
-
-
-
-
-
-
-
-
-
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.
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-