honeytree 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/honeydew.js CHANGED
@@ -1,6 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import path from "node:path";
4
+
3
5
  const command = process.argv[2];
6
+ const invokedAs = path.basename(process.argv[1] || "honeytree");
7
+
8
+ function printUsage() {
9
+ console.error("Usage:");
10
+ console.error(" honeytree show your forest");
11
+ console.error(" honeytree init create or migrate your forest");
12
+ console.error(" honeytree watch open the terminal forest");
13
+ console.error(" honeytree stats show a non-TUI summary");
14
+ console.error(" honeytree export write honeytree-badge.svg and FOREST.md");
15
+ }
4
16
 
5
17
  if (command === "init") {
6
18
  const { init } = await import("../src/commands/init.js");
@@ -11,11 +23,17 @@ if (command === "init") {
11
23
  } else if (command === "watch" || !command) {
12
24
  const { watch } = await import("../src/commands/watch.js");
13
25
  await watch();
26
+ } else if (command === "stats") {
27
+ const { stats } = await import("../src/commands/stats.js");
28
+ await stats();
14
29
  } else if (command === "plant") {
15
30
  const { plant } = await import("../src/commands/plant.js");
16
31
  await plant();
17
32
  } else {
18
33
  console.error(`Unknown command: ${command}`);
19
- console.error("Usage: honeytree [init | export | watch]");
34
+ if (invokedAs === "honeydew") {
35
+ console.error("Run `honeydew watch` or `honeytree init`.");
36
+ }
37
+ printUsage();
20
38
  process.exit(1);
21
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honeytree",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "A living pixel art forest that grows while you code",
5
5
  "type": "module",
6
6
  "bin": {
package/src/badge.js CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { readState } from "./core/state.js";
5
5
  import { buildTerminalScene } from "./renderers/terminal.js";
6
6
 
7
- const CELL = 8;
7
+ const CELL = 6;
8
8
  const BG = "#0d1117";
9
9
  const TEXT_PAD = 18;
10
10
 
@@ -63,4 +63,11 @@ export async function badge() {
63
63
  writeBadgeSVG(state, outPath);
64
64
 
65
65
  console.log(`Badge written to ${outPath}`);
66
+ console.log("");
67
+ console.log("Add this to your README to show your Honeytree forest.");
68
+ console.log("The badge links to https://github.com/Varun2009178/honeytree");
69
+ console.log("");
70
+ console.log(
71
+ `[![honeytree](./honeytree-badge.svg)](https://github.com/Varun2009178/honeytree)`,
72
+ );
66
73
  }
@@ -8,8 +8,9 @@ import { createActivityTracker } from "../tracker/activity.js";
8
8
  import { startFileTracker } from "../tracker/files.js";
9
9
  import { startGitTracker } from "../tracker/git.js";
10
10
 
11
- const FRAME_MS = 100;
12
- const POLL_MS = 1_000;
11
+ const FRAME_MS = 150;
12
+ const STATE_SYNC_MS = 1_000;
13
+ const GIT_POLL_MS = 30_000;
13
14
  const h = React.createElement;
14
15
 
15
16
  function buildCommandHint(width) {
@@ -27,6 +28,7 @@ function ForestWatchApp() {
27
28
  const [tick, setTick] = useState(0);
28
29
  const [state, setState] = useState(() => ensureState());
29
30
  const [width, setWidth] = useState(stdout.columns || 80);
31
+ const [height, setHeight] = useState(stdout.rows || 24);
30
32
 
31
33
  useInput((input, key) => {
32
34
  if (input === "q" || key.escape || key.ctrl && input === "c") {
@@ -35,7 +37,10 @@ function ForestWatchApp() {
35
37
  });
36
38
 
37
39
  useEffect(() => {
38
- const onResize = () => setWidth(stdout.columns || 80);
40
+ const onResize = () => {
41
+ setWidth(stdout.columns || 80);
42
+ setHeight(stdout.rows || 24);
43
+ };
39
44
  stdout.on("resize", onResize);
40
45
  return () => stdout.off("resize", onResize);
41
46
  }, [stdout]);
@@ -53,7 +58,7 @@ function ForestWatchApp() {
53
58
  if (latest) {
54
59
  setState(latest);
55
60
  }
56
- }, POLL_MS);
61
+ }, STATE_SYNC_MS);
57
62
  return () => clearInterval(syncTimer);
58
63
  }, []);
59
64
 
@@ -78,6 +83,7 @@ function ForestWatchApp() {
78
83
  const gitTracker = startGitTracker({
79
84
  cwd: process.cwd(),
80
85
  lastCommitHash: state.last_commit_hash,
86
+ pollMs: GIT_POLL_MS,
81
87
  async onCommit(commitHash) {
82
88
  const nextState = updateState((draft) => applyCommit(draft, { commitHash }));
83
89
  setState(nextState);
@@ -94,8 +100,8 @@ function ForestWatchApp() {
94
100
  }, []);
95
101
 
96
102
  const frame = useMemo(
97
- () => renderTerminalFrame(state, width, tick, { now: new Date() }),
98
- [state, tick, width],
103
+ () => renderTerminalFrame(state, width, tick, { now: new Date(), termHeight: height }),
104
+ [state, tick, width, height],
99
105
  );
100
106
  const commandHint = useMemo(() => buildCommandHint(width), [width]);
101
107
 
@@ -22,34 +22,40 @@ function hash(seed) {
22
22
  return ((value >>> 16) ^ value) >>> 0;
23
23
  }
24
24
 
25
- export function getClouds(width, tick = 0, skyPhase = "day") {
25
+ export function getClouds(width, tick = 0, skyPhase = "day", skyRows = 4) {
26
26
  const count = skyPhase === "night" ? 2 : 3;
27
27
  return Array.from({ length: count }, (_, index) => {
28
28
  const length = 7 + (index % 3) * 3;
29
29
  const offset = ((tick * (0.35 + index * 0.08)) + index * 17) % (width + length * 2);
30
+ const maxY = Math.max(2, skyRows - 1);
30
31
  return {
31
32
  x: Math.round(offset) - length,
32
- y: index % 2 === 0 ? 1 : 2,
33
+ y: index % 2 === 0 ? Math.round(maxY * 0.3) : Math.round(maxY * 0.6),
33
34
  length,
34
35
  };
35
36
  });
36
37
  }
37
38
 
38
- export function getStars(width, tick = 0) {
39
+ export function getStars(width, tick = 0, skyRows = 4) {
39
40
  const stars = [];
40
41
  for (let column = 0; column < width; column += 1) {
41
42
  const seeded = hash(column * 97);
42
43
  if (seeded % 7 !== 0) continue;
43
44
  stars.push({
44
45
  x: column,
45
- y: seeded % 3,
46
+ y: seeded % skyRows,
46
47
  twinkle: ((tick + seeded) % 20) / 20,
47
48
  });
48
49
  }
49
50
  return stars;
50
51
  }
51
52
 
52
- export function getAnimatedAnimals(state, width, tick = 0) {
53
+ export function getAnimatedAnimals(state, width, tick = 0, layout = null) {
54
+ const artRows = layout ? layout.artRows : 13;
55
+ const skyRows = layout ? layout.skyRows : 4;
56
+ const groundY = artRows - 2; // just above ground
57
+ const flyY = skyRows + 2; // flying creatures in upper forest
58
+
53
59
  return state.animals.map((animal, index) => {
54
60
  const speed = SPEEDS[animal.type] ?? 0.08;
55
61
  const direction = animal.direction === "left" ? -1 : 1;
@@ -60,10 +66,10 @@ export function getAnimatedAnimals(state, width, tick = 0) {
60
66
  x,
61
67
  y:
62
68
  animal.type === "butterfly"
63
- ? 6 + Math.round(Math.sin((tick + index * 5) / 4))
69
+ ? flyY + Math.round(Math.sin((tick + index * 5) / 4))
64
70
  : animal.type === "owl"
65
- ? 5 + Math.round(Math.sin((tick + index * 9) / 8))
66
- : 11,
71
+ ? flyY - 1 + Math.round(Math.sin((tick + index * 9) / 8))
72
+ : groundY,
67
73
  bob:
68
74
  animal.type === "rabbit"
69
75
  ? Math.abs(Math.sin((tick + index * 6) / 3))
@@ -105,12 +111,14 @@ export function getWeatherParticles(width, height, tick = 0, weather = "clear",
105
111
  }));
106
112
  }
107
113
 
108
- export function getDynamicScene(state, width, tick, environment) {
114
+ export function getDynamicScene(state, width, tick, environment, layout = null) {
115
+ const skyRows = layout ? layout.skyRows : 4;
116
+ const artRows = layout ? layout.artRows : 13;
109
117
  return {
110
- clouds: getClouds(width, tick, environment.sky.name),
111
- stars: environment.sky.name === "night" ? getStars(width, tick) : [],
112
- animals: getAnimatedAnimals(state, width, tick),
113
- particles: getWeatherParticles(width, 12, tick, environment.weather.name, environment.season.name),
118
+ clouds: getClouds(width, tick, environment.sky.name, skyRows),
119
+ stars: environment.sky.name === "night" ? getStars(width, tick, skyRows) : [],
120
+ animals: getAnimatedAnimals(state, width, tick, layout),
121
+ particles: getWeatherParticles(width, artRows, tick, environment.weather.name, environment.season.name),
114
122
  };
115
123
  }
116
124
 
package/src/core/state.js CHANGED
@@ -13,7 +13,9 @@ function todayString(now = new Date()) {
13
13
  }
14
14
 
15
15
  function resolveStateDir() {
16
- return process.env.HONEYTREE_DIR || path.join(os.homedir(), STATE_DIR_NAME);
16
+ if (process.env.HONEYTREE_DIR) return process.env.HONEYTREE_DIR;
17
+ // Per-project: state lives in the current project directory
18
+ return path.join(process.cwd(), STATE_DIR_NAME);
17
19
  }
18
20
 
19
21
  function resolveLegacyDir() {
package/src/markdown.js CHANGED
@@ -5,7 +5,7 @@ import { getEnvironmentSnapshot } from "./core/environment.js";
5
5
  import { readState } from "./core/state.js";
6
6
  import { renderTerminalFrame } from "./renderers/terminal.js";
7
7
 
8
- export function buildMarkdown(state) {
8
+ function buildMarkdown(state) {
9
9
  const environment = getEnvironmentSnapshot(new Date(), state.current_streak);
10
10
  const art = renderTerminalFrame(state, 80, 0, { color: false });
11
11
 
@@ -43,4 +43,5 @@ export async function generateForestMd() {
43
43
  fs.writeFileSync(outPath, md);
44
44
 
45
45
  console.log(`Written to ${outPath}`);
46
+ console.log("Tip: run `honeytree badge` to generate the badge SVG too.");
46
47
  }
@@ -10,16 +10,78 @@ import {
10
10
  materializeSprite,
11
11
  } from "../core/sprites.js";
12
12
 
13
- const SKY_ROWS = 4;
14
- const FOREST_ROWS = 8;
15
- const GROUND_ROWS = 1;
16
- const ART_ROWS = SKY_ROWS + FOREST_ROWS + GROUND_ROWS;
17
- const STATS_ROWS = 1;
13
+ export const SCENE_HEIGHT = 14;
18
14
 
19
- export const SCENE_HEIGHT = ART_ROWS + STATS_ROWS;
15
+ export function computeLayout(termHeight) {
16
+ if (!termHeight || termHeight < 16) {
17
+ return { skyRows: 4, forestRows: 8, groundRows: 1, artRows: 13 };
18
+ }
19
+
20
+ const available = termHeight - 2;
21
+ const skyRows = Math.max(4, Math.floor(available * 0.4));
22
+ const forestRows = Math.max(6, Math.floor(available * 0.45));
23
+ const groundRows = Math.max(2, Math.floor(available * 0.1));
24
+
25
+ const artRows = skyRows + forestRows + groundRows;
26
+ return { skyRows, forestRows, groundRows, artRows };
27
+ }
28
+
29
+ function mulberry32(seed) {
30
+ let s = seed | 0;
31
+ return function random() {
32
+ s = (s + 0x6d2b79f5) | 0;
33
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
34
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
35
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
36
+ };
37
+ }
38
+
39
+ function shuffleSeeded(items, rng) {
40
+ const result = [...items];
41
+ for (let i = result.length - 1; i > 0; i -= 1) {
42
+ const j = Math.floor(rng() * (i + 1));
43
+ [result[i], result[j]] = [result[j], result[i]];
44
+ }
45
+ return result;
46
+ }
20
47
 
21
- function createBuffer(width) {
22
- return Array.from({ length: ART_ROWS }, () =>
48
+ export function selectViewportTrees(trees, width, seed = 0) {
49
+ const maxVisible = 12;
50
+ if (trees.length === 0) return [];
51
+
52
+ const rng = mulberry32(seed);
53
+ const selected = shuffleSeeded(trees, rng).slice(0, Math.min(maxVisible, trees.length));
54
+ const rowNames = ["back", "mid", "front"];
55
+ const perRow = Math.ceil(selected.length / rowNames.length);
56
+ const assigned = selected.map((tree, index) => ({
57
+ ...tree,
58
+ row: rowNames[Math.min(rowNames.length - 1, Math.floor(index / perRow))],
59
+ }));
60
+
61
+ for (const rowName of rowNames) {
62
+ const rowTrees = assigned.filter((tree) => tree.row === rowName);
63
+ const count = rowTrees.length;
64
+ if (count === 0) continue;
65
+
66
+ const margin = rowName === "front" ? 10 : rowName === "mid" ? 8 : 6;
67
+ const clampedMargin = Math.min(margin, Math.max(4, Math.floor(width / 4)));
68
+ const minX = Math.min(clampedMargin, Math.max(0, width - 1));
69
+ const maxX = Math.max(minX, width - clampedMargin);
70
+ const usable = Math.max(1, maxX - minX);
71
+ const segmentWidth = usable / count;
72
+
73
+ for (let index = 0; index < count; index += 1) {
74
+ const center = minX + segmentWidth * (index + 0.5);
75
+ const jitter = (rng() - 0.5) * segmentWidth * 0.4;
76
+ rowTrees[index].screenX = Math.round(Math.max(minX, Math.min(maxX, center + jitter)));
77
+ }
78
+ }
79
+
80
+ return assigned;
81
+ }
82
+
83
+ function createBuffer(width, artRows) {
84
+ return Array.from({ length: artRows }, () =>
23
85
  Array.from({ length: width }, () => ({ char: " ", color: null, bg: null })),
24
86
  );
25
87
  }
@@ -36,12 +98,14 @@ function paintBg(buffer, x, y, bg) {
36
98
  buffer[y][x].bg = bg;
37
99
  }
38
100
 
39
- function compositeSprite(buffer, sprite, centerX, baseY, palette) {
101
+ function compositeSprite(buffer, sprite, centerX, baseY, palette, options = {}) {
40
102
  const cells = materializeSprite(sprite, palette);
41
103
  const offsetX = Math.round(centerX) - Math.floor(cells.width / 2);
104
+ const minY = options.minY ?? 0;
42
105
  for (let rowIndex = 0; rowIndex < cells.rows.length; rowIndex += 1) {
43
106
  const row = cells.rows[rowIndex];
44
107
  const y = baseY - rowIndex;
108
+ if (y < minY) continue;
45
109
  for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
46
110
  const [char, color] = row[columnIndex];
47
111
  if (!color) continue;
@@ -59,11 +123,12 @@ function colorize(text, color, bg, enabled) {
59
123
  return fn(text);
60
124
  }
61
125
 
62
- function drawSkyBackground(buffer, width, environment) {
126
+ function drawSkyBackground(buffer, width, environment, layout) {
127
+ const { skyRows, forestRows, artRows } = layout;
63
128
  const sky = environment.sky;
64
129
  // Sky rows: vertical gradient from top → mid → bottom
65
- for (let y = 0; y < SKY_ROWS; y++) {
66
- const t = y / Math.max(1, SKY_ROWS - 1);
130
+ for (let y = 0; y < skyRows; y++) {
131
+ const t = y / Math.max(1, skyRows - 1);
67
132
  let color;
68
133
  if (t < 0.5) {
69
134
  color = lerpColor(sky.top, sky.mid, t * 2);
@@ -75,28 +140,32 @@ function drawSkyBackground(buffer, width, environment) {
75
140
  }
76
141
  }
77
142
  // Forest rows: blend from sky.bottom toward ground
78
- for (let y = SKY_ROWS; y < SKY_ROWS + FOREST_ROWS; y++) {
79
- const t = (y - SKY_ROWS) / Math.max(1, FOREST_ROWS - 1);
143
+ for (let y = skyRows; y < skyRows + forestRows; y++) {
144
+ const t = (y - skyRows) / Math.max(1, forestRows - 1);
80
145
  const color = lerpColor(sky.bottom, environment.palette.groundDark, t * 0.7);
81
146
  for (let x = 0; x < width; x++) {
82
147
  paintBg(buffer, x, y, color);
83
148
  }
84
149
  }
85
- // Ground row: solid ground color
86
- for (let x = 0; x < width; x++) {
87
- paintBg(buffer, x, ART_ROWS - 1, environment.palette.groundDark);
150
+ // Ground rows: solid ground color
151
+ for (let y = skyRows + forestRows; y < artRows; y++) {
152
+ for (let x = 0; x < width; x++) {
153
+ paintBg(buffer, x, y, environment.palette.groundDark);
154
+ }
88
155
  }
89
156
  }
90
157
 
91
- function drawSky(buffer, width, environment, tick) {
158
+ function drawSky(buffer, width, environment, tick, layout) {
159
+ const { skyRows } = layout;
160
+
92
161
  if (environment.sky.name === "night") {
93
- const stars = getDynamicScene({ animals: [] }, width, tick, environment).stars;
162
+ const stars = getDynamicScene({ animals: [] }, width, tick, environment, layout).stars;
94
163
  for (const star of stars) {
95
164
  paint(buffer, star.x, star.y, star.twinkle > 0.5 ? "✦" : "·", environment.sky.haze);
96
165
  }
97
166
  }
98
167
 
99
- const clouds = getDynamicScene({ animals: [] }, width, tick, environment).clouds;
168
+ const clouds = getDynamicScene({ animals: [] }, width, tick, environment, layout).clouds;
100
169
  for (const cloud of clouds) {
101
170
  const glyph = environment.sky.name === "night" ? "░" : "▓";
102
171
  for (let offset = 0; offset < cloud.length; offset += 1) {
@@ -105,7 +174,9 @@ function drawSky(buffer, width, environment, tick) {
105
174
  }
106
175
 
107
176
  const celestialX = Math.round(((width - 6) * environment.sky.progress) + 3);
108
- const celestialY = environment.sky.name === "night" ? 1 : Math.max(0, 2 - Math.round(environment.sky.progress * 2));
177
+ const celestialY = environment.sky.name === "night"
178
+ ? Math.max(1, Math.round(skyRows * 0.2))
179
+ : Math.max(0, Math.round(skyRows * 0.4) - Math.round(environment.sky.progress * 2));
109
180
  paint(
110
181
  buffer,
111
182
  celestialX,
@@ -115,8 +186,8 @@ function drawSky(buffer, width, environment, tick) {
115
186
  );
116
187
  }
117
188
 
118
- function drawWeather(buffer, width, environment, tick) {
119
- const scene = getDynamicScene({ animals: [] }, width, tick, environment);
189
+ function drawWeather(buffer, width, environment, tick, layout) {
190
+ const scene = getDynamicScene({ animals: [] }, width, tick, environment, layout);
120
191
  if (environment.weather.name === "rain") {
121
192
  for (const drop of scene.particles) {
122
193
  if (drop.kind !== "rain") continue;
@@ -133,16 +204,17 @@ function drawWeather(buffer, width, environment, tick) {
133
204
 
134
205
  if (environment.weather.name === "rainbow") {
135
206
  const midpoint = Math.floor(width / 2);
207
+ const rainbowY = Math.max(1, Math.round(layout.skyRows * 0.3));
136
208
  const colors = ["#f94144", "#f8961e", "#f9c74f", "#90be6d", "#577590"];
137
209
  colors.forEach((color, index) => {
138
- paint(buffer, midpoint - 2 + index, 1 + (index % 2), "◜", color);
139
- paint(buffer, midpoint + 1 - index, 1 + (index % 2), "◝", color);
210
+ paint(buffer, midpoint - 2 + index, rainbowY + (index % 2), "◜", color);
211
+ paint(buffer, midpoint + 1 - index, rainbowY + (index % 2), "◝", color);
140
212
  });
141
213
  }
142
214
  }
143
215
 
144
- function drawSeasonParticles(buffer, width, environment, tick) {
145
- const scene = getDynamicScene({ animals: [] }, width, tick, environment);
216
+ function drawSeasonParticles(buffer, width, environment, tick, layout) {
217
+ const scene = getDynamicScene({ animals: [] }, width, tick, environment, layout);
146
218
  for (const particle of scene.particles) {
147
219
  if (particle.kind === "petal") {
148
220
  paint(buffer, Math.round(particle.x), Math.round(particle.y), "•", "#ffcad4");
@@ -163,58 +235,60 @@ function scaleX(virtualX, width) {
163
235
  return Math.round((virtualX / VIRTUAL_WIDTH) * width);
164
236
  }
165
237
 
166
- function spaceTrees(trees, width, minGap) {
167
- const sorted = [...trees].sort((a, b) => a.x_position - b.x_position);
168
- const result = [];
169
- let lastScreenX = -Infinity;
170
- for (const tree of sorted) {
171
- const screenX = Math.round((tree.x_position / VIRTUAL_WIDTH) * width);
172
- if (screenX - lastScreenX >= minGap) {
173
- result.push(tree);
174
- lastScreenX = screenX;
238
+ function desaturatePalette(palette, skyBottom, amount) {
239
+ const result = {};
240
+ for (const key of Object.keys(palette)) {
241
+ const value = palette[key];
242
+ if (typeof value === "string" && value.startsWith("#")) {
243
+ result[key] = lerpColor(value, skyBottom, amount);
244
+ } else {
245
+ result[key] = value;
175
246
  }
176
247
  }
177
248
  return result;
178
249
  }
179
250
 
180
- function drawForest(buffer, width, state, environment, tick) {
181
- const treeBaseY = SKY_ROWS + FOREST_ROWS - 1;
182
- const isSunsetSilhouette = environment.sky.name === "sunset";
183
- const silhouettePalette = {};
184
- if (isSunsetSilhouette) {
185
- for (const key of Object.keys(environment.palette)) {
186
- silhouettePalette[key] = "#1a1a2e";
187
- }
251
+ function silhouettePalette(palette) {
252
+ const result = {};
253
+ for (const key of Object.keys(palette)) {
254
+ result[key] = key.toLowerCase().includes("light") ? "#24243f" : "#111827";
188
255
  }
256
+ return result;
257
+ }
189
258
 
259
+ function drawForest(buffer, width, viewportTrees, state, environment, tick, layout) {
260
+ const { skyRows, forestRows, artRows, groundRows } = layout;
261
+ const treeBaseY = skyRows + forestRows - 1;
190
262
  const crystalPalette = getCrystalPalette(tick);
263
+ const isSunsetSilhouette = environment.sky.name === "sunset";
191
264
 
192
- // Separate trees by row, then filter for spacing
193
- const minGap = { back: 6, mid: 10, front: 14 };
194
- const rows = ["back", "mid", "front"];
195
- for (const targetRow of rows) {
196
- const rowTrees = state.trees.filter((t) => (t.row || "front") === targetRow);
197
- const spaced = spaceTrees(rowTrees, width, minGap[targetRow]);
198
- const rowY = targetRow === "back" ? treeBaseY - 2 : targetRow === "mid" ? treeBaseY - 1 : treeBaseY;
265
+ const rowOffset = { back: 4, mid: 2, front: 0 };
266
+ const rowDesaturation = { back: 0.3, mid: 0.1, front: 0 };
267
+ for (const targetRow of ["back", "mid", "front"]) {
268
+ const rowTrees = viewportTrees.filter((tree) => tree.row === targetRow);
269
+ const rowY = treeBaseY - rowOffset[targetRow];
199
270
 
200
- for (const tree of spaced) {
271
+ for (const tree of rowTrees) {
201
272
  let palette = environment.palette;
202
- if (tree.species === "crystal_tree") {
203
- palette = { ...environment.palette, ...crystalPalette };
204
- }
205
273
  if (isSunsetSilhouette) {
206
- palette = silhouettePalette;
274
+ palette = silhouettePalette(palette);
275
+ } else if (rowDesaturation[targetRow] > 0) {
276
+ palette = desaturatePalette(palette, environment.sky.bottom, rowDesaturation[targetRow]);
277
+ }
278
+ if (tree.species === "crystal_tree") {
279
+ palette = { ...palette, ...crystalPalette };
207
280
  }
208
- compositeSprite(buffer, getTreeSprite(tree.species), scaleX(tree.x_position, width), rowY, palette);
281
+ compositeSprite(buffer, getTreeSprite(tree.species), tree.screenX, rowY, palette, { minY: skyRows });
209
282
  }
210
283
  }
211
284
 
285
+ const groundY = artRows - groundRows;
212
286
  for (const element of state.ground_elements) {
213
- compositeSprite(buffer, getGroundElementSprite(element.type), scaleX(element.x_position, width), ART_ROWS - 1, environment.palette);
287
+ compositeSprite(buffer, getGroundElementSprite(element.type), scaleX(element.x_position, width), groundY, environment.palette);
214
288
  }
215
289
 
216
- const scene = getDynamicScene(state, width, tick, environment);
217
- const maxAnimals = Math.max(2, Math.min(4, Math.floor(width / 25)));
290
+ const scene = getDynamicScene(state, width, tick, environment, layout);
291
+ const maxAnimals = Math.max(2, Math.min(3, Math.floor(width / 40)));
218
292
  const visibleAnimals = scene.animals.slice(0, maxAnimals);
219
293
  for (const animal of visibleAnimals) {
220
294
  if (animal.type === "owl" && environment.sky.name !== "night") {
@@ -224,20 +298,80 @@ function drawForest(buffer, width, state, environment, tick) {
224
298
  buffer,
225
299
  getAnimalSprite(animal.type),
226
300
  animal.x,
227
- Math.min(ART_ROWS - 2, Math.round(animal.y)),
301
+ Math.min(artRows - groundRows - 1, Math.round(animal.y)),
228
302
  environment.palette,
229
303
  );
230
304
  }
231
305
  }
232
306
 
233
- function drawGround(buffer, width, environment) {
234
- for (let x = 0; x < width; x += 1) {
235
- paint(buffer, x, ART_ROWS - 1, "▄", x % 3 === 0 ? environment.palette.groundLight : environment.palette.ground);
307
+ function drawGround(buffer, width, environment, layout) {
308
+ const { skyRows, forestRows, groundRows } = layout;
309
+ const groundStart = skyRows + forestRows;
310
+
311
+ for (let row = 0; row < groundRows; row += 1) {
312
+ const y = groundStart + row;
313
+ if (row === 0) {
314
+ for (let x = 0; x < width; x += 1) {
315
+ const char = x % 5 === 0 ? "▓" : "▄";
316
+ const color = x % 3 === 0 ? environment.palette.grassLight : environment.palette.grass;
317
+ paint(buffer, x, y, char, color);
318
+ }
319
+ } else if (row === 1) {
320
+ for (let x = 0; x < width; x += 1) {
321
+ const color = x % 7 === 0 ? environment.palette.groundLight : environment.palette.ground;
322
+ paint(buffer, x, y, "▄", color);
323
+ }
324
+ } else {
325
+ for (let x = 0; x < width; x += 1) {
326
+ paint(buffer, x, y, "▄", environment.palette.groundDark);
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ function drawFog(buffer, width, layout, environment) {
333
+ const { skyRows, forestRows } = layout;
334
+ const fogColor = "#5a7a5a";
335
+ const fogColumns = [
336
+ { offset: 0, char: "▒", opacity: 0.6 },
337
+ { offset: 1, char: "▒", opacity: 0.45 },
338
+ { offset: 2, char: "░", opacity: 0.3 },
339
+ { offset: 3, char: "░", opacity: 0.15 },
340
+ ];
341
+
342
+ for (let y = skyRows; y < skyRows + forestRows; y += 1) {
343
+ for (const fog of fogColumns) {
344
+ const x = fog.offset;
345
+ if (x >= width) continue;
346
+ const cell = buffer[y][x];
347
+ if (cell.bg) {
348
+ paintBg(buffer, x, y, lerpColor(cell.bg, fogColor, fog.opacity));
349
+ }
350
+ if (cell.color) {
351
+ paint(buffer, x, y, cell.char, lerpColor(cell.color, fogColor, fog.opacity));
352
+ } else {
353
+ paint(buffer, x, y, fog.char, lerpColor(environment.sky.bottom, fogColor, fog.opacity));
354
+ }
355
+ }
356
+
357
+ for (const fog of fogColumns) {
358
+ const x = width - 1 - fog.offset;
359
+ if (x < 0) continue;
360
+ const cell = buffer[y][x];
361
+ if (cell.bg) {
362
+ paintBg(buffer, x, y, lerpColor(cell.bg, fogColor, fog.opacity));
363
+ }
364
+ if (cell.color) {
365
+ paint(buffer, x, y, cell.char, lerpColor(cell.color, fogColor, fog.opacity));
366
+ } else {
367
+ paint(buffer, x, y, fog.char, lerpColor(environment.sky.bottom, fogColor, fog.opacity));
368
+ }
369
+ }
236
370
  }
237
371
  }
238
372
 
239
373
  function buildStatsLine(state, environment, width, color) {
240
- const trees = `${state.trees.length} tree${state.trees.length === 1 ? "" : "s"}`;
374
+ const trees = `${state.trees.length} tree${state.trees.length === 1 ? "" : "s"} in your forest`;
241
375
  const animals = `${state.animals.length} animal${state.animals.length === 1 ? "" : "s"}`;
242
376
  const streak = `${state.current_streak} day streak`;
243
377
  const weather = `${environment.season.icon} ${environment.season.label} ${environment.weather.label}`;
@@ -259,18 +393,25 @@ export function buildTerminalScene(state, termWidth = 80, tick = 0, options = {}
259
393
  const width = Math.max(20, termWidth);
260
394
  const date = options.now instanceof Date ? options.now : new Date();
261
395
  const environment = getEnvironmentSnapshot(date, state.current_streak ?? 0);
262
- const buffer = createBuffer(width);
263
-
264
- drawSkyBackground(buffer, width, environment);
265
- drawSky(buffer, width, environment, tick);
266
- drawWeather(buffer, width, environment, tick);
267
- drawSeasonParticles(buffer, width, environment, tick);
268
- drawForest(buffer, width, state, environment, tick);
269
- drawGround(buffer, width, environment);
396
+ const layout = computeLayout(options.termHeight || null);
397
+ const buffer = createBuffer(width, layout.artRows);
398
+ const startOfYear = new Date(date.getFullYear(), 0, 0);
399
+ const dayOfYear = Math.floor((date - startOfYear) / 86400000);
400
+ const viewportTrees = selectViewportTrees(state.trees, width, dayOfYear);
401
+
402
+ drawSkyBackground(buffer, width, environment, layout);
403
+ drawSky(buffer, width, environment, tick, layout);
404
+ drawWeather(buffer, width, environment, tick, layout);
405
+ drawSeasonParticles(buffer, width, environment, tick, layout);
406
+ drawForest(buffer, width, viewportTrees, state, environment, tick, layout);
407
+ drawGround(buffer, width, environment, layout);
408
+ drawFog(buffer, width, layout, environment);
270
409
 
271
410
  return {
272
411
  width,
273
412
  environment,
413
+ layout,
414
+ viewportTrees,
274
415
  buffer,
275
416
  statsLine: buildStatsLine(state, environment, width, options.color !== false),
276
417
  };
@@ -294,4 +435,3 @@ export function renderTerminalFrame(state, termWidth = 80, tick = 0, options = {
294
435
  lines.push(scene.statsLine);
295
436
  return lines.join("\n");
296
437
  }
297
-