forest: map with trees

This commit is contained in:
azykov@mail.ru
2026-09-04 15:21:02 +03:00
parent d154880c4c
commit 325aa8915d
14 changed files with 922 additions and 386 deletions
+6
View File
@@ -5,6 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>forest</title> <title>forest</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+26
View File
@@ -10,6 +10,8 @@
"dependencies": { "dependencies": {
"@incgame/engine": "file:../engine", "@incgame/engine": "file:../engine",
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
"mobx": "^7.0.3",
"mobx-react-lite": "^5.0.3",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
@@ -1010,6 +1012,30 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+2
View File
@@ -12,6 +12,8 @@
"dependencies": { "dependencies": {
"@incgame/engine": "file:../engine", "@incgame/engine": "file:../engine",
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
"mobx": "^7.0.3",
"mobx-react-lite": "^5.0.3",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
+115 -169
View File
@@ -1,184 +1,130 @@
.counter { .app {
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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px; height: 100%;
place-content: center; }
place-items: center;
.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; flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
} }
#next-steps { .inventory-slot {
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;
display: flex; display: flex;
align-items: center;
gap: 8px; gap: 8px;
margin: 32px 0 0; padding: 6px 12px;
background: rgba(0, 0, 0, 0.25);
.logo { border: 3px solid var(--panel-border);
height: 18px; font-size: 14px;
}
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;
}
}
} }
#spacer { .game-map {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative; 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, .entity {
&::after { position: absolute;
content: ''; transform: translate(-50%, -50%);
position: absolute; pointer-events: none;
top: -4.5px; }
border: 5px solid transparent;
}
&::before { .entity.log-shadow {
left: 0; width: 20px;
border-left-color: var(--border); 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 { 25% {
right: 0; transform: translate(-2px, 1px) rotate(-6deg);
border-right-color: var(--border); }
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);
} }
} }
+12 -119
View File
@@ -1,125 +1,18 @@
import { useState } from 'react' import { useState } from 'react';
import { deserializeGame, type Game } from '@incgame/engine' import GameMap from './components/GameMap';
import heroImg from './assets/hero.png' import Topbar from './components/Topbar';
import reactLogo from './assets/react.svg' import { GameStore } from './game/GameStore';
import viteLogo from './assets/vite.svg' import './App.css';
import './App.css'
function App() { function App() {
const [game] = useState<Game>(() => deserializeGame({ time: 0, nodes: [] })) const [store] = useState(() => new GameStore());
const [time, setTime] = useState(() => game.time.toString())
function handleTick() {
game.tick(0.1)
setTime(game.time.toString())
}
return ( return (
<> <div className="app">
<section id="center"> <Topbar store={store} />
<div className="hero"> <GameMap store={store} />
<img src={heroImg} className="base" width="170" height="179" alt="" /> </div>
<img src={reactLogo} className="framework" alt="React logo" /> );
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button type="button" className="counter" onClick={handleTick}>
@incgame/engine time: {time}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
} }
export default App export default App;
+155
View File
@@ -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 (
<div>
<div className="entity log-shadow" style={{ left: log.x, top: log.y }} />
<div className="entity log" style={{ left: log.x, top: log.y - log.z, zIndex: Math.floor(log.y - log.z) }}>
<PixelSprite pixels={LOG_SPRITE} size={20} />
</div>
</div>
);
});
const TreeEntity = observer(function TreeEntity({
tree,
store,
onPointerDown,
onPointerUp,
}: {
tree: TreeState;
store: GameStore;
onPointerDown: (e: PointerEvent<HTMLDivElement>, id: string) => void;
onPointerUp: (e: PointerEvent<HTMLDivElement>) => void;
}) {
const isChopping = tree.id === store.heldTreeId && store.isChopping;
return (
<div>
<div className="entity tree-shadow" style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) - 1 }} />
<div
className={`entity tree ${isChopping ? 'tree--chopping' : ''}`}
style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) }}
onPointerDown={(e) => onPointerDown(e, tree.id)}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<PixelSprite pixels={TREE_SPRITE} size={64} />
</div>
</div>
);
});
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
return (
<>
<div
className="entity character-shadow"
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
/>
<div
className={`entity character ${store.isMoving ? 'character--walking' : ''}`}
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
>
<PixelSprite pixels={CHARACTER_SPRITE} size={48} />
</div>
</>
);
});
function GameMap({ store }: Props) {
const containerRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>, 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<HTMLDivElement>) {
e.stopPropagation();
store.release();
}
function handleMapPointerDown(e: PointerEvent<HTMLDivElement>) {
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 (
<div
className="game-map"
ref={containerRef}
onPointerDown={handleMapPointerDown}
onPointerUp={store.release}
onPointerCancel={store.release}
>
{ready && (
<>
{store.logDrops.map((log) => (
<LogEntity key={log.id} log={log} />
))}
{store.trees.map((tree) => (
<TreeEntity
key={tree.id}
tree={tree}
store={store}
onPointerDown={handleTreePointerDown}
onPointerUp={handleTreePointerUp}
/>
))}
<CharacterEntity store={store} />
</>
)}
</div>
);
}
export default observer(GameMap);
+35
View File
@@ -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 (
<svg
className={className}
width={size}
height={cols > 0 ? size * (rows / cols) : size}
viewBox={`0 0 ${cols} ${rows}`}
shapeRendering="crispEdges"
>
{pixels.flatMap((row, y) =>
row.map((color, x) =>
color ? <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={color} /> : null,
),
)}
</svg>
);
}
// `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) <rect> elements each time.
export default memo(PixelSprite);
+27
View File
@@ -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 (
<div className="topbar">
<h1 className="topbar__title">Forest</h1>
<div className="spacer" />
<div className="inventory-slot">
<PixelSprite pixels={LOG_SPRITE} size={28} />
<span>{store.inventory.logs}</span>
</div>
<div className="inventory-slot">
<PixelSprite pixels={COIN_SPRITE} size={24} />
<span>{store.inventory.coins}</span>
</div>
</div>
);
}
export default observer(Topbar);
+317
View File
@@ -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<string> | null = null;
for (const log of this.logDrops) {
if (log.phase === 'landed')
continue;
const flight = log.flight!;
flight.elapsed += deltaTime;
const t = Math.min(1, flight.elapsed / flight.duration);
log.x = flight.fromX + (flight.toX - flight.fromX) * t;
log.y = flight.fromY + (flight.toY - flight.fromY) * t;
log.z = flight.arcHeight * 4 * t * (1 - t);
if (t < 1)
continue;
if (log.phase === 'falling') {
log.phase = 'landed';
log.flight = undefined;
log.z = 0;
} else {
// Collecting flight finished: the log has reached the character.
(collectedIds ??= new Set()).add(log.id);
this.inventory.logs += 1;
}
}
if (collectedIds)
this.logDrops = this.logDrops.filter((log) => !collectedIds!.has(log.id));
}
/** Starts a landed log flying into the inventory once the character gets close enough. */
private updatePickups() {
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;
}
}
}
+41
View File
@@ -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;
+135
View File
@@ -0,0 +1,135 @@
/** Pixel grids for 8-bit-style sprites. Each cell is a CSS color, or '' for transparent. */
export type PixelGrid = string[][];
function grid(rows: string[], palette: Record<string, string>): PixelGrid {
return rows.map((row) => [...row].map((ch) => palette[ch] ?? ''));
}
const CHARACTER_PALETTE = {
O: '#1b1611',
H: '#4a2f1c',
S: '#f0c090',
s: '#d9a873',
B: '#3f6fb0',
b: '#2f5488',
P: '#2c2c3e',
p: '#20202e',
F: '#2a1f14',
};
export const CHARACTER_SPRITE: PixelGrid = grid(
[
'....OOOOOOOO....',
'..OHHHHHHHHHHO..',
'.OHHHHHHHHHHHHO.',
'OHHHHHHHHHHHHHHO',
'OHHSSSSSSSSSSHHO',
'OSSSSOSSSSOSSSSO',
'OSSSSSSssSSSSSSO',
'.OSSSSSSSSSSSSO.',
'......OSSO......',
'...OBBBBBBBBO...',
'..OBBBBBBBBBBO..',
'.OSBBBBBBBBBBSO.',
'.OSBBbBBBBbBBSO.',
'.OSBBbBBBBbBBSO.',
'..OBBbBBBBbBBO..',
'..OPPpPPPPpPPO..',
'..OPPpPPPPpPPO..',
'..OPPpO..OpPPO..',
'..OPPpO..OpPPO..',
'..OPPpO..OpPPO..',
'..OFFFO..OFFFO..',
'.OFFFFO..OFFFFO.',
],
CHARACTER_PALETTE,
);
const TREE_PALETTE = {
O: '#14290f',
G: '#2e7d32',
g: '#1b5e20',
H: '#66bb6a',
T: '#6b4226',
t: '#4e2f1a',
n: '#8a5a34',
};
export const TREE_SPRITE: PixelGrid = grid(
[
'......OggO......',
'....OGGggGGO....',
'..OGGGGggGGGGO..',
'.OGgGGGHHGGGgGO.',
'OGgGGGGHHGGGGgGO',
'OGGgGGGggGGGgGGO',
'OGgGGGgGGgGGGgGO',
'OGGgGGGGGGGGgGGO',
'OGgGGGGggGGGGgGO',
'.OGgGGGggGGGgGO.',
'..OGgGGggGGgGO..',
'...OGgGggGgGO...',
'....OGgGGgGO....',
'......OggO......',
'......OTTO......',
'......TTTT......',
'......TttT......',
'......tnnt......',
'......TTTT......',
'......TOOT......',
],
TREE_PALETTE,
);
const LOG_PALETTE = {
O: '#2a1a0d',
B: '#7a4a2b',
C: '#a9703f',
};
export const LOG_SPRITE: PixelGrid = grid(
[
'..OOOOOOOOOOOOOOOO..',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'OBBBBBBBBBBBBBBBBBBO',
'OBCCCCCCCCCCCCCCCCBO',
'..OOOOOOOOOOOOOOOO..',
],
LOG_PALETTE,
);
const COIN_PALETTE = {
Y: '#f5c518',
D: '#c99a0e',
W: '#fff2b0',
E: '#a97c08',
};
export const COIN_SPRITE: PixelGrid = grid(
[
'.....YYYYYY.....',
'...YYYYYYYYYY...',
'..YWWYYYYYYYYY..',
'.YYWWYYYYYYYYYY.',
'.YYYYYYYYYYYYYY.',
'YYYYYDDDDDDYYYYY',
'YYYYYDYYYYDYYYYY',
'YYYYYDYYYYDYYYYY',
'YYYYYDYYYYDYYYYY',
'YYYYYDYYYYDYYYYY',
'YYYYYDDDDDDYYYYY',
'.YYYYYYYYYYYYYY.',
'.YYYYYYYYYYEEYY.',
'..YYYYYYYYYEEY..',
'...YYYYYYYYYY...',
'.....YYYYYY.....',
],
COIN_PALETTE,
);
+38
View File
@@ -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;
}
+12 -98
View File
@@ -1,111 +1,25 @@
:root { :root {
--text: #6b6375; --panel-bg: #3a2a1e;
--text-h: #08060d; --panel-border: #1b1611;
--bg: #fff; --text: #f5e9d0;
--border: #e5e4e7; --accent: #f5c518;
--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;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; font: 16px/1.4 'Press Start 2P', ui-monospace, Consolas, monospace;
--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;
color: var(--text); color: var(--text);
background: var(--bg); color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
} }
@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; box-sizing: border-box;
} }
html,
body { body {
height: 100%;
margin: 0; margin: 0;
overflow: hidden;
} }
h1, #root {
h2 { height: 100%;
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);
} }
+1
View File
@@ -21,6 +21,7 @@ export default defineConfig({
exclude: ['@incgame/engine'], exclude: ['@incgame/engine'],
}, },
server: { server: {
host: '0.0.0.0',
fs: { fs: {
// The engine package lives outside this project's root (a sibling directory reached // 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. // through a node_modules symlink); allow Vite to serve/watch files from there too.