honeytree 1.0.2 → 1.0.4
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/README.md +99 -0
- package/bin/honeydew.js +7 -1
- package/package.json +1 -1
- package/src/badge.js +92 -0
- package/src/markdown.js +77 -0
- package/src/plant.js +24 -1
- package/src/renderer.js +125 -9
- package/src/state.js +2 -0
package/README.md
CHANGED
|
@@ -38,6 +38,74 @@ From then on, every time Claude Code responds to a prompt, a new tree is planted
|
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
|
41
|
+
## Streaks
|
|
42
|
+
|
|
43
|
+
Honeytree tracks your coding streak — consecutive days where you use Claude Code.
|
|
44
|
+
|
|
45
|
+
- **Active streak**: The viewer and badge show your current streak count (e.g. `7-day streak`)
|
|
46
|
+
- **Broken streak**: Miss a day and your forest starts **wilting** — trees desaturate toward brown, and fog rolls in across the scene
|
|
47
|
+
- **Recovery**: Your next prompt resets the streak to 1 and clears the wilting immediately
|
|
48
|
+
|
|
49
|
+
The longer you go without coding, the worse it gets:
|
|
50
|
+
|
|
51
|
+
| Days idle | Effect |
|
|
52
|
+
|----------:|--------|
|
|
53
|
+
| 1 | Light desaturation, sparse fog |
|
|
54
|
+
| 2 | Noticeable browning, moderate fog |
|
|
55
|
+
| 3 | Heavy browning, dense fog |
|
|
56
|
+
| 4+ | Near-dead forest, thick fog |
|
|
57
|
+
|
|
58
|
+
Plant a tree to bring it all back to life.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Badge
|
|
63
|
+
|
|
64
|
+
Generate a badge for your GitHub README that shows your forest stats and links back to [Honeytree](https://github.com/Varun2009178/honeytree):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
honeytree badge
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This creates a `honeytree-badge.svg` file in your current directory and prints the markdown to embed it:
|
|
71
|
+
|
|
72
|
+
```markdown
|
|
73
|
+
[](https://github.com/Varun2009178/honeytree)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The badge displays your tree count and streak status. It links to the [Honeytree repo](https://github.com/Varun2009178/honeytree) so anyone who sees it can install it themselves.
|
|
77
|
+
|
|
78
|
+
| State | Badge color | Example |
|
|
79
|
+
|-------|-------------|---------|
|
|
80
|
+
| Active streak | Green | `42 trees · 7d streak` |
|
|
81
|
+
| Wilting | Orange-red | `42 trees · wilting` |
|
|
82
|
+
| No streak data | Grey | `42 trees` |
|
|
83
|
+
|
|
84
|
+
Re-run `honeytree badge` any time to update the SVG with your latest stats. Commit it to your repo to keep it current.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## FOREST.md
|
|
89
|
+
|
|
90
|
+
Generate a shareable markdown snapshot of your forest:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
honeytree md
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This creates a `FOREST.md` in your current directory with:
|
|
97
|
+
|
|
98
|
+
- Your Honeytree badge (links to the [Honeytree repo](https://github.com/Varun2009178/honeytree))
|
|
99
|
+
- Stats: tree count, streak, biome
|
|
100
|
+
- A plain-text rendering of your forest (tree silhouettes, stars, ground)
|
|
101
|
+
- Total prompts and forest age
|
|
102
|
+
|
|
103
|
+
Commit `FOREST.md` to your repo root so your team can see the forest. When teammates see it, they can install Honeytree themselves — one install spreads to the whole team.
|
|
104
|
+
|
|
105
|
+
Run `honeytree badge` first to generate the SVG, then `honeytree md` to generate the markdown that embeds it.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
41
109
|
## Biomes
|
|
42
110
|
|
|
43
111
|
Your forest evolves visually as it grows — the sky, ground, and atmosphere all change:
|
|
@@ -70,12 +138,43 @@ Each species has 4 growth stages (seed, sapling, young, full). Existing trees gr
|
|
|
70
138
|
|
|
71
139
|
---
|
|
72
140
|
|
|
141
|
+
## CLI Reference
|
|
142
|
+
|
|
143
|
+
| Command | Description |
|
|
144
|
+
|---------|-------------|
|
|
145
|
+
| `honeytree init` | Create forest and register Claude Code hook |
|
|
146
|
+
| `honeytree` | Launch the live viewer |
|
|
147
|
+
| `honeytree plant` | Plant a tree manually (normally runs via hook) |
|
|
148
|
+
| `honeytree badge` | Generate `honeytree-badge.svg` in current directory |
|
|
149
|
+
| `honeytree md` | Generate `FOREST.md` in current directory |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
73
153
|
## Viewer
|
|
74
154
|
|
|
75
155
|
The viewer adapts to your terminal width — expand your terminal and new trees will spread across the full width.
|
|
76
156
|
|
|
77
157
|
Press `Ctrl+C` to exit. The viewer shows a summary of your forest when you close it.
|
|
78
158
|
|
|
159
|
+
### Reading the Stats Bar
|
|
160
|
+
|
|
161
|
+
Below your forest you'll see a stats bar like this:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
honeytree · 42 trees · 7-day streak · ████████░░░░ next: oak [woodland]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Here's what each part means:
|
|
168
|
+
|
|
169
|
+
| Segment | What it tells you |
|
|
170
|
+
|---------|-------------------|
|
|
171
|
+
| `42 trees` | Total trees in your forest — one planted per prompt, never deleted |
|
|
172
|
+
| `7-day streak` | Consecutive days you've used Claude Code. Resets to 1 if you skip a day |
|
|
173
|
+
| `wilting (2d idle)` | Appears instead of streak when you've been inactive — your forest is dying |
|
|
174
|
+
| `████████░░░░` | Progress bar toward the next milestone (10, 25, 50, 100, 250, 500, 1000 trees) |
|
|
175
|
+
| `next: oak` | The species of the next tree that will be planted |
|
|
176
|
+
| `[woodland]` | Your current biome — evolves as your tree count grows |
|
|
177
|
+
|
|
79
178
|
---
|
|
80
179
|
|
|
81
180
|
## Requirements
|
package/bin/honeydew.js
CHANGED
|
@@ -8,11 +8,17 @@ if (command === "init") {
|
|
|
8
8
|
} else if (command === "plant") {
|
|
9
9
|
const { plant } = await import("../src/plant.js");
|
|
10
10
|
await plant();
|
|
11
|
+
} else if (command === "badge") {
|
|
12
|
+
const { badge } = await import("../src/badge.js");
|
|
13
|
+
await badge();
|
|
14
|
+
} else if (command === "md") {
|
|
15
|
+
const { generateForestMd } = await import("../src/markdown.js");
|
|
16
|
+
await generateForestMd();
|
|
11
17
|
} else if (!command) {
|
|
12
18
|
const { viewer } = await import("../src/viewer.js");
|
|
13
19
|
await viewer();
|
|
14
20
|
} else {
|
|
15
21
|
console.error(`Unknown command: ${command}`);
|
|
16
|
-
console.error("Usage: honeytree [init|plant]");
|
|
22
|
+
console.error("Usage: honeytree [init|plant|badge|md]");
|
|
17
23
|
process.exit(1);
|
|
18
24
|
}
|
package/package.json
CHANGED
package/src/badge.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readForest } from "./state.js";
|
|
5
|
+
|
|
6
|
+
const CHAR_WIDTH = 6.8;
|
|
7
|
+
const PADDING = 10;
|
|
8
|
+
const HEIGHT = 20;
|
|
9
|
+
|
|
10
|
+
function sectionWidth(text) {
|
|
11
|
+
return Math.round(text.length * CHAR_WIDTH + PADDING * 2);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildMessage(forest) {
|
|
15
|
+
const count = forest.trees.length;
|
|
16
|
+
const streak = forest.streak || 0;
|
|
17
|
+
const treePart = `${count} tree${count === 1 ? "" : "s"}`;
|
|
18
|
+
|
|
19
|
+
if (!forest.lastActiveDate) return treePart;
|
|
20
|
+
|
|
21
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
22
|
+
const a = new Date(forest.lastActiveDate + "T00:00:00");
|
|
23
|
+
const b = new Date(today + "T00:00:00");
|
|
24
|
+
const idle = Math.round((b - a) / (24 * 60 * 60 * 1000));
|
|
25
|
+
|
|
26
|
+
if (idle > 0) return `${treePart} · wilting`;
|
|
27
|
+
if (streak > 0) return `${treePart} · ${streak}d streak`;
|
|
28
|
+
return treePart;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function badgeColor(forest) {
|
|
32
|
+
if (!forest.lastActiveDate) return "#555";
|
|
33
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
34
|
+
const a = new Date(forest.lastActiveDate + "T00:00:00");
|
|
35
|
+
const b = new Date(today + "T00:00:00");
|
|
36
|
+
const idle = Math.round((b - a) / (24 * 60 * 60 * 1000));
|
|
37
|
+
if (idle > 0) return "#c4653a";
|
|
38
|
+
return "#2ea043";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function generateSVG(label, message, color) {
|
|
42
|
+
const lw = sectionWidth(label);
|
|
43
|
+
const mw = sectionWidth(message);
|
|
44
|
+
const tw = lw + mw;
|
|
45
|
+
const lx = lw / 2;
|
|
46
|
+
const mx = lw + mw / 2;
|
|
47
|
+
|
|
48
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${tw}" height="${HEIGHT}" role="img" aria-label="${label}: ${message}">
|
|
49
|
+
<title>${label}: ${message}</title>
|
|
50
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
51
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
52
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
53
|
+
</linearGradient>
|
|
54
|
+
<clipPath id="r"><rect width="${tw}" height="${HEIGHT}" rx="3" fill="#fff"/></clipPath>
|
|
55
|
+
<g clip-path="url(#r)">
|
|
56
|
+
<rect width="${lw}" height="${HEIGHT}" fill="#555"/>
|
|
57
|
+
<rect x="${lw}" width="${mw}" height="${HEIGHT}" fill="${color}"/>
|
|
58
|
+
<rect width="${tw}" height="${HEIGHT}" fill="url(#s)"/>
|
|
59
|
+
</g>
|
|
60
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
|
61
|
+
<text aria-hidden="true" x="${lx}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
|
|
62
|
+
<text x="${lx}" y="14">${label}</text>
|
|
63
|
+
<text aria-hidden="true" x="${mx}" y="15" fill="#010101" fill-opacity=".3">${message}</text>
|
|
64
|
+
<text x="${mx}" y="14">${message}</text>
|
|
65
|
+
</g>
|
|
66
|
+
</svg>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function badge() {
|
|
70
|
+
const forest = readForest();
|
|
71
|
+
if (!forest) {
|
|
72
|
+
console.error('No forest found. Run "honeytree init" first.');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const label = "honeytree";
|
|
77
|
+
const message = buildMessage(forest);
|
|
78
|
+
const color = badgeColor(forest);
|
|
79
|
+
const svg = generateSVG(label, message, color);
|
|
80
|
+
|
|
81
|
+
const outPath = path.resolve("honeytree-badge.svg");
|
|
82
|
+
fs.writeFileSync(outPath, svg);
|
|
83
|
+
|
|
84
|
+
console.log(`Badge written to ${outPath}`);
|
|
85
|
+
console.log("");
|
|
86
|
+
console.log("Add this to your README to show your Honeytree stats.");
|
|
87
|
+
console.log("The badge links to https://github.com/Varun2009178/honeytree");
|
|
88
|
+
console.log("");
|
|
89
|
+
console.log(
|
|
90
|
+
`[](https://github.com/Varun2009178/honeytree)`,
|
|
91
|
+
);
|
|
92
|
+
}
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readForest } from "./state.js";
|
|
5
|
+
import { renderPlainText, getWiltFactor } from "./renderer.js";
|
|
6
|
+
|
|
7
|
+
function getBiomeLabel(count) {
|
|
8
|
+
if (count < 10) return "clearing";
|
|
9
|
+
if (count < 25) return "grove";
|
|
10
|
+
if (count < 50) return "woodland";
|
|
11
|
+
if (count < 100) return "old growth";
|
|
12
|
+
return "ancient forest";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getDayCount(createdAt) {
|
|
16
|
+
const created = new Date(createdAt).getTime();
|
|
17
|
+
const diff = Date.now() - created;
|
|
18
|
+
return Math.max(1, Math.floor(diff / (24 * 60 * 60 * 1000)) + 1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildMarkdown(forest) {
|
|
22
|
+
const count = forest.trees.length;
|
|
23
|
+
const streak = forest.streak || 0;
|
|
24
|
+
const biome = getBiomeLabel(count);
|
|
25
|
+
const wilt = getWiltFactor(forest.lastActiveDate);
|
|
26
|
+
const days = getDayCount(forest.createdAt);
|
|
27
|
+
const width = Math.min(forest.viewerWidth || 60, 80);
|
|
28
|
+
|
|
29
|
+
const art = renderPlainText(forest, width);
|
|
30
|
+
|
|
31
|
+
const statParts = [`**${count} tree${count === 1 ? "" : "s"}**`];
|
|
32
|
+
if (wilt > 0) {
|
|
33
|
+
const a = new Date(forest.lastActiveDate + "T00:00:00");
|
|
34
|
+
const b = new Date(new Date().toISOString().slice(0, 10) + "T00:00:00");
|
|
35
|
+
const idle = Math.round((b - a) / (24 * 60 * 60 * 1000));
|
|
36
|
+
statParts.push(`**wilting (${idle}d idle)**`);
|
|
37
|
+
} else if (streak > 0) {
|
|
38
|
+
statParts.push(`**${streak}-day streak**`);
|
|
39
|
+
}
|
|
40
|
+
statParts.push(`**${biome}**`);
|
|
41
|
+
|
|
42
|
+
const lines = [
|
|
43
|
+
`<div align="center">`,
|
|
44
|
+
``,
|
|
45
|
+
`[](https://github.com/Varun2009178/honeytree)`,
|
|
46
|
+
``,
|
|
47
|
+
statParts.join(" · "),
|
|
48
|
+
``,
|
|
49
|
+
"```",
|
|
50
|
+
art,
|
|
51
|
+
"```",
|
|
52
|
+
``,
|
|
53
|
+
`${forest.totalPrompts} prompts over ${days} day${days === 1 ? "" : "s"}`,
|
|
54
|
+
``,
|
|
55
|
+
`<sub>Grown with <a href="https://github.com/Varun2009178/honeytree">honeytree</a> — a forest that grows in your terminal every time you use Claude Code</sub>`,
|
|
56
|
+
``,
|
|
57
|
+
`</div>`,
|
|
58
|
+
``,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function generateForestMd() {
|
|
65
|
+
const forest = readForest();
|
|
66
|
+
if (!forest) {
|
|
67
|
+
console.error('No forest found. Run "honeytree init" first.');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const md = buildMarkdown(forest);
|
|
72
|
+
const outPath = path.resolve("FOREST.md");
|
|
73
|
+
fs.writeFileSync(outPath, md);
|
|
74
|
+
|
|
75
|
+
console.log(`Written to ${outPath}`);
|
|
76
|
+
console.log("Tip: run `honeytree badge` to generate the badge SVG too.");
|
|
77
|
+
}
|
package/src/plant.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getSprite, TREE_TYPES } from "./sprites.js";
|
|
2
2
|
import { createEmptyForest, readForest, writeForest } from "./state.js";
|
|
3
3
|
|
|
4
|
-
const MIN_GAP =
|
|
4
|
+
const MIN_GAP = 4;
|
|
5
5
|
const DEFAULT_WIDTH = 80;
|
|
6
6
|
|
|
7
7
|
function getPlantWidth(forest) {
|
|
@@ -53,10 +53,33 @@ function nudgeGrowth(growth) {
|
|
|
53
53
|
return Math.min(1, Math.round(nextGrowth * 100) / 100);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function daysBetween(dateA, dateB) {
|
|
57
|
+
const a = new Date(dateA + "T00:00:00");
|
|
58
|
+
const b = new Date(dateB + "T00:00:00");
|
|
59
|
+
return Math.round(Math.abs(b - a) / (24 * 60 * 60 * 1000));
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
export async function plant() {
|
|
57
63
|
const forest = readForest() ?? createEmptyForest();
|
|
58
64
|
const width = getPlantWidth(forest);
|
|
59
65
|
|
|
66
|
+
// Update streak
|
|
67
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
68
|
+
if (forest.lastActiveDate) {
|
|
69
|
+
const gap = daysBetween(forest.lastActiveDate, today);
|
|
70
|
+
if (gap === 0) {
|
|
71
|
+
// Same day — streak stays (ensure at least 1)
|
|
72
|
+
forest.streak = Math.max(forest.streak || 0, 1);
|
|
73
|
+
} else if (gap === 1) {
|
|
74
|
+
forest.streak = (forest.streak || 1) + 1;
|
|
75
|
+
} else {
|
|
76
|
+
forest.streak = 1;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
forest.streak = 1;
|
|
80
|
+
}
|
|
81
|
+
forest.lastActiveDate = today;
|
|
82
|
+
|
|
60
83
|
for (const tree of forest.trees) {
|
|
61
84
|
tree.growth = nudgeGrowth(tree.growth);
|
|
62
85
|
}
|
package/src/renderer.js
CHANGED
|
@@ -13,10 +13,76 @@ export const SCENE_HEIGHT =
|
|
|
13
13
|
|
|
14
14
|
const STATS_ACCENT = "#f5a50b";
|
|
15
15
|
const STATS_TEXT = "#8e8a84";
|
|
16
|
+
const STATS_WARN = "#c4653a";
|
|
17
|
+
const STREAK_COLOR = "#e8a33a";
|
|
16
18
|
const BAR_FILL = "#6cb95e";
|
|
17
19
|
const BAR_EMPTY = "#3d3d3d";
|
|
18
20
|
const MILESTONES = [10, 25, 50, 100, 250, 500, 1000];
|
|
19
21
|
|
|
22
|
+
// Wilting — lerp toward dry brown when idle
|
|
23
|
+
const WILT_TARGET = { r: 0x8a, g: 0x6a, b: 0x4a };
|
|
24
|
+
|
|
25
|
+
function parseHex(hex) {
|
|
26
|
+
const h = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
27
|
+
return {
|
|
28
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
29
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
30
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toHex({ r, g, b }) {
|
|
35
|
+
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
|
|
36
|
+
return `#${c(r)}${c(g)}${c(b)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function wiltColor(hex, factor) {
|
|
40
|
+
if (factor <= 0) return hex;
|
|
41
|
+
const c = parseHex(hex);
|
|
42
|
+
return toHex({
|
|
43
|
+
r: c.r + (WILT_TARGET.r - c.r) * factor,
|
|
44
|
+
g: c.g + (WILT_TARGET.g - c.g) * factor,
|
|
45
|
+
b: c.b + (WILT_TARGET.b - c.b) * factor,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getWiltFactor(lastActiveDate) {
|
|
50
|
+
if (!lastActiveDate) return 0;
|
|
51
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
52
|
+
const a = new Date(lastActiveDate + "T00:00:00");
|
|
53
|
+
const b = new Date(today + "T00:00:00");
|
|
54
|
+
const days = Math.round((b - a) / (24 * 60 * 60 * 1000));
|
|
55
|
+
if (days <= 0) return 0;
|
|
56
|
+
if (days === 1) return 0.25;
|
|
57
|
+
if (days === 2) return 0.45;
|
|
58
|
+
if (days === 3) return 0.65;
|
|
59
|
+
return Math.min(0.85, 0.65 + (days - 3) * 0.05);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fog — procedural haze that thickens with inactivity
|
|
63
|
+
const FOG_CHARS = ["░", "░", "▒"];
|
|
64
|
+
const FOG_COLOR_UPPER = "#9a9a9a";
|
|
65
|
+
const FOG_COLOR_LOWER = "#6a6a6a";
|
|
66
|
+
|
|
67
|
+
function applyFog(buffer, wilt, width) {
|
|
68
|
+
if (wilt <= 0) return;
|
|
69
|
+
// Higher wilt → lower threshold → more fog
|
|
70
|
+
const threshold = Math.max(3, Math.round(18 * (1 - wilt)));
|
|
71
|
+
const fogStart = SKY_ROWS - 2; // creep into lower sky
|
|
72
|
+
const fogEnd = SKY_ROWS + TREE_ROWS + GROUND_ROWS;
|
|
73
|
+
|
|
74
|
+
for (let y = Math.max(0, fogStart); y < fogEnd; y += 1) {
|
|
75
|
+
for (let x = 0; x < width; x += 1) {
|
|
76
|
+
const h = hash(x * 31 + y * 97 + 12345);
|
|
77
|
+
if (h % threshold !== 0) continue;
|
|
78
|
+
const fogChar = FOG_CHARS[h % FOG_CHARS.length];
|
|
79
|
+
const blend = (y - fogStart) / (fogEnd - fogStart);
|
|
80
|
+
const fogColor = blend > 0.5 ? FOG_COLOR_LOWER : FOG_COLOR_UPPER;
|
|
81
|
+
buffer[y][x] = { char: fogChar, color: fogColor };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
20
86
|
// Biomes evolve as the forest grows — never resets, only gets richer
|
|
21
87
|
const BIOMES = [
|
|
22
88
|
{ // 0-9: sparse clearing
|
|
@@ -116,11 +182,19 @@ function getNextTreeType(treeCount) {
|
|
|
116
182
|
return TREE_TYPES[treeCount % TREE_TYPES.length];
|
|
117
183
|
}
|
|
118
184
|
|
|
119
|
-
function
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
185
|
+
function buildStreakSegment(forest) {
|
|
186
|
+
const wilt = getWiltFactor(forest.lastActiveDate);
|
|
187
|
+
const streak = forest.streak || 0;
|
|
188
|
+
|
|
189
|
+
if (wilt > 0) {
|
|
190
|
+
const a = new Date(forest.lastActiveDate + "T00:00:00");
|
|
191
|
+
const b = new Date(new Date().toISOString().slice(0, 10) + "T00:00:00");
|
|
192
|
+
const idle = Math.round((b - a) / (24 * 60 * 60 * 1000));
|
|
193
|
+
return chalk.hex(STATS_WARN)(`wilting (${idle}d idle)`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (streak <= 0) return chalk.hex(STATS_TEXT)("no streak");
|
|
197
|
+
return chalk.hex(STREAK_COLOR)(`${streak}-day streak`);
|
|
124
198
|
}
|
|
125
199
|
|
|
126
200
|
function buildStatsLine(forest, biome) {
|
|
@@ -136,10 +210,10 @@ function buildStatsLine(forest, biome) {
|
|
|
136
210
|
return (
|
|
137
211
|
chalk.hex(STATS_ACCENT)(" honeytree") +
|
|
138
212
|
chalk.hex(STATS_TEXT)(
|
|
139
|
-
` · ${treeCount} tree${treeCount === 1 ? "" : "s"} ·
|
|
140
|
-
forest.createdAt,
|
|
141
|
-
)} day${getDayCount(forest.createdAt) === 1 ? "" : "s"} · `,
|
|
213
|
+
` · ${treeCount} tree${treeCount === 1 ? "" : "s"} · `,
|
|
142
214
|
) +
|
|
215
|
+
buildStreakSegment(forest) +
|
|
216
|
+
chalk.hex(STATS_TEXT)(" · ") +
|
|
143
217
|
bar +
|
|
144
218
|
chalk.hex(STATS_TEXT)(` next: ${getNextTreeType(treeCount)}`) +
|
|
145
219
|
chalk.hex("#555555")(` [${biome.label}]`)
|
|
@@ -151,6 +225,7 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
151
225
|
const buffer = createBuffer(width);
|
|
152
226
|
const groundStart = SKY_ROWS + TREE_ROWS;
|
|
153
227
|
const biome = getBiome(forest.trees.length);
|
|
228
|
+
const wilt = getWiltFactor(forest.lastActiveDate);
|
|
154
229
|
|
|
155
230
|
for (const star of generateStars(width, biome, options.twinkleSeed ?? 0)) {
|
|
156
231
|
buffer[star.y][star.x] = { char: star.char, color: star.color };
|
|
@@ -170,11 +245,19 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
170
245
|
compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
|
|
171
246
|
}
|
|
172
247
|
|
|
248
|
+
applyFog(buffer, wilt, width);
|
|
249
|
+
|
|
173
250
|
const lines = [];
|
|
174
251
|
for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS; y += 1) {
|
|
175
252
|
let line = "";
|
|
176
253
|
for (const cell of buffer[y]) {
|
|
177
|
-
|
|
254
|
+
if (!cell.color) {
|
|
255
|
+
line += cell.char;
|
|
256
|
+
} else {
|
|
257
|
+
// Apply wilting to tree rows and ground (skip sky)
|
|
258
|
+
const color = wilt > 0 && y >= SKY_ROWS ? wiltColor(cell.color, wilt) : cell.color;
|
|
259
|
+
line += chalk.hex(color)(cell.char);
|
|
260
|
+
}
|
|
178
261
|
}
|
|
179
262
|
lines.push(line);
|
|
180
263
|
}
|
|
@@ -184,3 +267,36 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
184
267
|
|
|
185
268
|
return lines.join("\n");
|
|
186
269
|
}
|
|
270
|
+
|
|
271
|
+
export function renderPlainText(forest, width = 60) {
|
|
272
|
+
const w = Math.max(40, Math.min(width, 80));
|
|
273
|
+
const buffer = createBuffer(w);
|
|
274
|
+
const groundStart = SKY_ROWS + TREE_ROWS;
|
|
275
|
+
const biome = getBiome(forest.trees.length);
|
|
276
|
+
|
|
277
|
+
for (const star of generateStars(w, biome, 0)) {
|
|
278
|
+
buffer[star.y][star.x] = { char: star.char, color: star.color };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
|
|
282
|
+
for (let x = 0; x < w; x += 1) {
|
|
283
|
+
buffer[groundStart + rowIndex][x] = { char: "█", color: "#333" };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const treeBaseY = groundStart - 1;
|
|
288
|
+
for (const tree of forest.trees) {
|
|
289
|
+
compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const lines = [];
|
|
293
|
+
for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS; y += 1) {
|
|
294
|
+
let line = "";
|
|
295
|
+
for (const cell of buffer[y]) {
|
|
296
|
+
line += cell.char;
|
|
297
|
+
}
|
|
298
|
+
lines.push(line.trimEnd());
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return lines.join("\n");
|
|
302
|
+
}
|