honeytree 1.1.0 → 1.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honeytree",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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
  }
@@ -27,6 +27,7 @@ function ForestWatchApp() {
27
27
  const [tick, setTick] = useState(0);
28
28
  const [state, setState] = useState(() => ensureState());
29
29
  const [width, setWidth] = useState(stdout.columns || 80);
30
+ const [height, setHeight] = useState(stdout.rows || 24);
30
31
 
31
32
  useInput((input, key) => {
32
33
  if (input === "q" || key.escape || key.ctrl && input === "c") {
@@ -35,7 +36,10 @@ function ForestWatchApp() {
35
36
  });
36
37
 
37
38
  useEffect(() => {
38
- const onResize = () => setWidth(stdout.columns || 80);
39
+ const onResize = () => {
40
+ setWidth(stdout.columns || 80);
41
+ setHeight(stdout.rows || 24);
42
+ };
39
43
  stdout.on("resize", onResize);
40
44
  return () => stdout.off("resize", onResize);
41
45
  }, [stdout]);
@@ -94,8 +98,8 @@ function ForestWatchApp() {
94
98
  }, []);
95
99
 
96
100
  const frame = useMemo(
97
- () => renderTerminalFrame(state, width, tick, { now: new Date() }),
98
- [state, tick, width],
101
+ () => renderTerminalFrame(state, width, tick, { now: new Date(), termHeight: height }),
102
+ [state, tick, width, height],
99
103
  );
100
104
  const commandHint = useMemo(() => buildCommandHint(width), [width]);
101
105
 
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
 
3
3
  import { getDynamicScene, getCrystalPalette } from "../core/animation.js";
4
- import { getEnvironmentSnapshot } from "../core/environment.js";
4
+ import { getEnvironmentSnapshot, lerpColor } from "../core/environment.js";
5
5
  import { VIRTUAL_WIDTH } from "../core/progression.js";
6
6
  import {
7
7
  getAnimalSprite,
@@ -10,32 +10,102 @@ 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
+ }
47
+
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
+ }));
20
60
 
21
- function createBuffer(width) {
22
- return Array.from({ length: ART_ROWS }, () =>
23
- Array.from({ length: width }, () => ({ char: " ", color: null })),
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 }, () =>
85
+ Array.from({ length: width }, () => ({ char: " ", color: null, bg: null })),
24
86
  );
25
87
  }
26
88
 
27
89
  function paint(buffer, x, y, char, color) {
28
90
  if (y < 0 || y >= buffer.length) return;
29
91
  if (x < 0 || x >= buffer[0].length) return;
30
- buffer[y][x] = { char, color };
92
+ buffer[y][x] = { char, color, bg: buffer[y][x].bg };
93
+ }
94
+
95
+ function paintBg(buffer, x, y, bg) {
96
+ if (y < 0 || y >= buffer.length) return;
97
+ if (x < 0 || x >= buffer[0].length) return;
98
+ buffer[y][x].bg = bg;
31
99
  }
32
100
 
33
- function compositeSprite(buffer, sprite, centerX, baseY, palette) {
101
+ function compositeSprite(buffer, sprite, centerX, baseY, palette, options = {}) {
34
102
  const cells = materializeSprite(sprite, palette);
35
103
  const offsetX = Math.round(centerX) - Math.floor(cells.width / 2);
104
+ const minY = options.minY ?? 0;
36
105
  for (let rowIndex = 0; rowIndex < cells.rows.length; rowIndex += 1) {
37
106
  const row = cells.rows[rowIndex];
38
107
  const y = baseY - rowIndex;
108
+ if (y < minY) continue;
39
109
  for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
40
110
  const [char, color] = row[columnIndex];
41
111
  if (!color) continue;
@@ -44,22 +114,58 @@ function compositeSprite(buffer, sprite, centerX, baseY, palette) {
44
114
  }
45
115
  }
46
116
 
47
- function colorize(text, color, enabled) {
48
- if (!enabled || !color) {
49
- return text;
117
+ function colorize(text, color, bg, enabled) {
118
+ if (!enabled) return text;
119
+ if (!color && !bg) return text;
120
+ let fn = chalk;
121
+ if (bg) fn = fn.bgHex(bg);
122
+ if (color) fn = fn.hex(color);
123
+ return fn(text);
124
+ }
125
+
126
+ function drawSkyBackground(buffer, width, environment, layout) {
127
+ const { skyRows, forestRows, artRows } = layout;
128
+ const sky = environment.sky;
129
+ // Sky rows: vertical gradient from top → mid → bottom
130
+ for (let y = 0; y < skyRows; y++) {
131
+ const t = y / Math.max(1, skyRows - 1);
132
+ let color;
133
+ if (t < 0.5) {
134
+ color = lerpColor(sky.top, sky.mid, t * 2);
135
+ } else {
136
+ color = lerpColor(sky.mid, sky.bottom, (t - 0.5) * 2);
137
+ }
138
+ for (let x = 0; x < width; x++) {
139
+ paintBg(buffer, x, y, color);
140
+ }
141
+ }
142
+ // Forest rows: blend from sky.bottom toward ground
143
+ for (let y = skyRows; y < skyRows + forestRows; y++) {
144
+ const t = (y - skyRows) / Math.max(1, forestRows - 1);
145
+ const color = lerpColor(sky.bottom, environment.palette.groundDark, t * 0.7);
146
+ for (let x = 0; x < width; x++) {
147
+ paintBg(buffer, x, y, color);
148
+ }
149
+ }
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
+ }
50
155
  }
51
- return chalk.hex(color)(text);
52
156
  }
53
157
 
54
- function drawSky(buffer, width, environment, tick) {
158
+ function drawSky(buffer, width, environment, tick, layout) {
159
+ const { skyRows } = layout;
160
+
55
161
  if (environment.sky.name === "night") {
56
- const stars = getDynamicScene({ animals: [] }, width, tick, environment).stars;
162
+ const stars = getDynamicScene({ animals: [] }, width, tick, environment, layout).stars;
57
163
  for (const star of stars) {
58
164
  paint(buffer, star.x, star.y, star.twinkle > 0.5 ? "✦" : "·", environment.sky.haze);
59
165
  }
60
166
  }
61
167
 
62
- const clouds = getDynamicScene({ animals: [] }, width, tick, environment).clouds;
168
+ const clouds = getDynamicScene({ animals: [] }, width, tick, environment, layout).clouds;
63
169
  for (const cloud of clouds) {
64
170
  const glyph = environment.sky.name === "night" ? "░" : "▓";
65
171
  for (let offset = 0; offset < cloud.length; offset += 1) {
@@ -68,7 +174,9 @@ function drawSky(buffer, width, environment, tick) {
68
174
  }
69
175
 
70
176
  const celestialX = Math.round(((width - 6) * environment.sky.progress) + 3);
71
- 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));
72
180
  paint(
73
181
  buffer,
74
182
  celestialX,
@@ -78,8 +186,8 @@ function drawSky(buffer, width, environment, tick) {
78
186
  );
79
187
  }
80
188
 
81
- function drawWeather(buffer, width, environment, tick) {
82
- const scene = getDynamicScene({ animals: [] }, width, tick, environment);
189
+ function drawWeather(buffer, width, environment, tick, layout) {
190
+ const scene = getDynamicScene({ animals: [] }, width, tick, environment, layout);
83
191
  if (environment.weather.name === "rain") {
84
192
  for (const drop of scene.particles) {
85
193
  if (drop.kind !== "rain") continue;
@@ -96,16 +204,17 @@ function drawWeather(buffer, width, environment, tick) {
96
204
 
97
205
  if (environment.weather.name === "rainbow") {
98
206
  const midpoint = Math.floor(width / 2);
207
+ const rainbowY = Math.max(1, Math.round(layout.skyRows * 0.3));
99
208
  const colors = ["#f94144", "#f8961e", "#f9c74f", "#90be6d", "#577590"];
100
209
  colors.forEach((color, index) => {
101
- paint(buffer, midpoint - 2 + index, 1 + (index % 2), "◜", color);
102
- 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);
103
212
  });
104
213
  }
105
214
  }
106
215
 
107
- function drawSeasonParticles(buffer, width, environment, tick) {
108
- const scene = getDynamicScene({ animals: [] }, width, tick, environment);
216
+ function drawSeasonParticles(buffer, width, environment, tick, layout) {
217
+ const scene = getDynamicScene({ animals: [] }, width, tick, environment, layout);
109
218
  for (const particle of scene.particles) {
110
219
  if (particle.kind === "petal") {
111
220
  paint(buffer, Math.round(particle.x), Math.round(particle.y), "•", "#ffcad4");
@@ -126,43 +235,51 @@ function scaleX(virtualX, width) {
126
235
  return Math.round((virtualX / VIRTUAL_WIDTH) * width);
127
236
  }
128
237
 
129
- function drawForest(buffer, width, state, environment, tick) {
130
- const treeBaseY = SKY_ROWS + FOREST_ROWS - 1;
131
- const isSunsetSilhouette = environment.sky.name === "sunset";
132
- const silhouettePalette = {};
133
- if (isSunsetSilhouette) {
134
- for (const key of Object.keys(environment.palette)) {
135
- silhouettePalette[key] = "#1a1a2e";
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;
136
246
  }
137
247
  }
248
+ return result;
249
+ }
138
250
 
251
+ function drawForest(buffer, width, viewportTrees, state, environment, tick, layout) {
252
+ const { skyRows, forestRows, artRows, groundRows } = layout;
253
+ const treeBaseY = skyRows + forestRows - 1;
139
254
  const crystalPalette = getCrystalPalette(tick);
140
255
 
141
- // Sort trees by row: back first, then mid, then front
142
- const rowOrder = { back: 0, mid: 1, front: 2 };
143
- const sortedTrees = [...state.trees].sort(
144
- (a, b) => (rowOrder[a.row] ?? 2) - (rowOrder[b.row] ?? 2),
145
- );
256
+ const rowOffset = { back: 4, mid: 2, front: 0 };
257
+ const rowDesaturation = { back: 0.3, mid: 0.1, front: 0 };
258
+ for (const targetRow of ["back", "mid", "front"]) {
259
+ const rowTrees = viewportTrees.filter((tree) => tree.row === targetRow);
260
+ const rowY = treeBaseY - rowOffset[targetRow];
146
261
 
147
- for (const tree of sortedTrees) {
148
- const row = tree.row || "front";
149
- const rowY = row === "back" ? treeBaseY - 2 : row === "mid" ? treeBaseY - 1 : treeBaseY;
150
- let palette = environment.palette;
151
- if (tree.species === "crystal_tree") {
152
- palette = { ...environment.palette, ...crystalPalette };
262
+ for (const tree of rowTrees) {
263
+ let palette = environment.palette;
264
+ if (rowDesaturation[targetRow] > 0) {
265
+ palette = desaturatePalette(palette, environment.sky.bottom, rowDesaturation[targetRow]);
266
+ }
267
+ if (tree.species === "crystal_tree") {
268
+ palette = { ...palette, ...crystalPalette };
269
+ }
270
+ compositeSprite(buffer, getTreeSprite(tree.species), tree.screenX, rowY, palette, { minY: skyRows });
153
271
  }
154
- if (isSunsetSilhouette) {
155
- palette = silhouettePalette;
156
- }
157
- compositeSprite(buffer, getTreeSprite(tree.species), scaleX(tree.x_position, width), rowY, palette);
158
272
  }
159
273
 
274
+ const groundY = artRows - groundRows;
160
275
  for (const element of state.ground_elements) {
161
- compositeSprite(buffer, getGroundElementSprite(element.type), scaleX(element.x_position, width), ART_ROWS - 1, environment.palette);
276
+ compositeSprite(buffer, getGroundElementSprite(element.type), scaleX(element.x_position, width), groundY, environment.palette);
162
277
  }
163
278
 
164
- const scene = getDynamicScene(state, width, tick, environment);
165
- for (const animal of scene.animals) {
279
+ const scene = getDynamicScene(state, width, tick, environment, layout);
280
+ const maxAnimals = Math.max(2, Math.min(3, Math.floor(width / 40)));
281
+ const visibleAnimals = scene.animals.slice(0, maxAnimals);
282
+ for (const animal of visibleAnimals) {
166
283
  if (animal.type === "owl" && environment.sky.name !== "night") {
167
284
  continue;
168
285
  }
@@ -170,29 +287,89 @@ function drawForest(buffer, width, state, environment, tick) {
170
287
  buffer,
171
288
  getAnimalSprite(animal.type),
172
289
  animal.x,
173
- Math.min(ART_ROWS - 2, Math.round(animal.y)),
290
+ Math.min(artRows - groundRows - 1, Math.round(animal.y)),
174
291
  environment.palette,
175
292
  );
176
293
  }
177
294
  }
178
295
 
179
- function drawGround(buffer, width, environment) {
180
- for (let x = 0; x < width; x += 1) {
181
- paint(buffer, x, ART_ROWS - 1, "▄", x % 3 === 0 ? environment.palette.groundLight : environment.palette.ground);
296
+ function drawGround(buffer, width, environment, layout) {
297
+ const { skyRows, forestRows, groundRows } = layout;
298
+ const groundStart = skyRows + forestRows;
299
+
300
+ for (let row = 0; row < groundRows; row += 1) {
301
+ const y = groundStart + row;
302
+ if (row === 0) {
303
+ for (let x = 0; x < width; x += 1) {
304
+ const char = x % 5 === 0 ? "▓" : "▄";
305
+ const color = x % 3 === 0 ? environment.palette.grassLight : environment.palette.grass;
306
+ paint(buffer, x, y, char, color);
307
+ }
308
+ } else if (row === 1) {
309
+ for (let x = 0; x < width; x += 1) {
310
+ const color = x % 7 === 0 ? environment.palette.groundLight : environment.palette.ground;
311
+ paint(buffer, x, y, "▄", color);
312
+ }
313
+ } else {
314
+ for (let x = 0; x < width; x += 1) {
315
+ paint(buffer, x, y, "▄", environment.palette.groundDark);
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ function drawFog(buffer, width, layout, environment) {
322
+ const { skyRows, forestRows } = layout;
323
+ const fogColor = "#5a7a5a";
324
+ const fogColumns = [
325
+ { offset: 0, char: "▒", opacity: 0.6 },
326
+ { offset: 1, char: "▒", opacity: 0.45 },
327
+ { offset: 2, char: "░", opacity: 0.3 },
328
+ { offset: 3, char: "░", opacity: 0.15 },
329
+ ];
330
+
331
+ for (let y = skyRows; y < skyRows + forestRows; y += 1) {
332
+ for (const fog of fogColumns) {
333
+ const x = fog.offset;
334
+ if (x >= width) continue;
335
+ const cell = buffer[y][x];
336
+ if (cell.bg) {
337
+ paintBg(buffer, x, y, lerpColor(cell.bg, fogColor, fog.opacity));
338
+ }
339
+ if (cell.color) {
340
+ paint(buffer, x, y, cell.char, lerpColor(cell.color, fogColor, fog.opacity));
341
+ } else {
342
+ paint(buffer, x, y, fog.char, lerpColor(environment.sky.bottom, fogColor, fog.opacity));
343
+ }
344
+ }
345
+
346
+ for (const fog of fogColumns) {
347
+ const x = width - 1 - fog.offset;
348
+ if (x < 0) continue;
349
+ const cell = buffer[y][x];
350
+ if (cell.bg) {
351
+ paintBg(buffer, x, y, lerpColor(cell.bg, fogColor, fog.opacity));
352
+ }
353
+ if (cell.color) {
354
+ paint(buffer, x, y, cell.char, lerpColor(cell.color, fogColor, fog.opacity));
355
+ } else {
356
+ paint(buffer, x, y, fog.char, lerpColor(environment.sky.bottom, fogColor, fog.opacity));
357
+ }
358
+ }
182
359
  }
183
360
  }
184
361
 
185
362
  function buildStatsLine(state, environment, width, color) {
186
- const trees = `${state.trees.length} tree${state.trees.length === 1 ? "" : "s"}`;
363
+ const trees = `${state.trees.length} tree${state.trees.length === 1 ? "" : "s"} in your forest`;
187
364
  const animals = `${state.animals.length} animal${state.animals.length === 1 ? "" : "s"}`;
188
365
  const streak = `${state.current_streak} day streak`;
189
366
  const weather = `${environment.season.icon} ${environment.season.label} ${environment.weather.label}`;
190
- const separator = colorize(" • ", "#6b7280", color);
367
+ const separator = colorize(" • ", "#6b7280", null, color);
191
368
  const line = [
192
- colorize("🌲 " + trees, "#7bd389", color),
193
- colorize("🐇 " + animals, "#f4d35e", color),
194
- colorize("🔥 " + streak, "#ee964b", color),
195
- colorize(weather, "#d4d4d8", color),
369
+ colorize("🌲 " + trees, "#7bd389", null, color),
370
+ colorize("🐇 " + animals, "#f4d35e", null, color),
371
+ colorize("🔥 " + streak, "#ee964b", null, color),
372
+ colorize(weather, "#d4d4d8", null, color),
196
373
  ].join(separator);
197
374
 
198
375
  if (line.length >= width) {
@@ -205,17 +382,24 @@ export function buildTerminalScene(state, termWidth = 80, tick = 0, options = {}
205
382
  const width = Math.max(20, termWidth);
206
383
  const date = options.now instanceof Date ? options.now : new Date();
207
384
  const environment = getEnvironmentSnapshot(date, state.current_streak ?? 0);
208
- const buffer = createBuffer(width);
385
+ const layout = computeLayout(options.termHeight || null);
386
+ const buffer = createBuffer(width, layout.artRows);
387
+ const startOfYear = new Date(date.getFullYear(), 0, 0);
388
+ const dayOfYear = Math.floor((date - startOfYear) / 86400000);
389
+ const viewportTrees = selectViewportTrees(state.trees, width, dayOfYear);
209
390
 
210
- drawSky(buffer, width, environment, tick);
211
- drawWeather(buffer, width, environment, tick);
212
- drawSeasonParticles(buffer, width, environment, tick);
213
- drawForest(buffer, width, state, environment, tick);
214
- drawGround(buffer, width, environment);
391
+ drawSkyBackground(buffer, width, environment, layout);
392
+ drawSky(buffer, width, environment, tick, layout);
393
+ drawWeather(buffer, width, environment, tick, layout);
394
+ drawSeasonParticles(buffer, width, environment, tick, layout);
395
+ drawForest(buffer, width, viewportTrees, state, environment, tick, layout);
396
+ drawGround(buffer, width, environment, layout);
397
+ drawFog(buffer, width, layout, environment);
215
398
 
216
399
  return {
217
400
  width,
218
401
  environment,
402
+ layout,
219
403
  buffer,
220
404
  statsLine: buildStatsLine(state, environment, width, options.color !== false),
221
405
  };
@@ -233,10 +417,9 @@ export function renderTerminalFrame(state, termWidth = 80, tick = 0, options = {
233
417
  const scene = buildTerminalScene(state, termWidth, tick, options);
234
418
  const lines = scene.buffer.map((row) =>
235
419
  row
236
- .map((cell) => colorize(cell.char, cell.color, options.color !== false))
420
+ .map((cell) => colorize(cell.char, cell.color, cell.bg, options.color !== false))
237
421
  .join(""),
238
422
  );
239
423
  lines.push(scene.statsLine);
240
424
  return lines.join("\n");
241
425
  }
242
-