honeytree 1.1.4 → 1.1.6
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 +169 -22
- package/bin/honeydew.js +12 -27
- package/package.json +8 -14
- package/src/badge.js +34 -16
- package/src/init.js +86 -0
- package/src/markdown.js +43 -13
- package/src/migrate.js +47 -0
- package/src/plant.js +124 -0
- package/src/renderer.js +436 -0
- package/src/sprites.js +302 -0
- package/src/state.js +53 -0
- package/src/viewer.js +234 -0
- package/src/commands/export.js +0 -23
- package/src/commands/init.js +0 -70
- package/src/commands/plant.js +0 -11
- package/src/commands/stats.js +0 -48
- package/src/commands/watch.js +0 -159
- package/src/core/animation.js +0 -154
- package/src/core/biomeDetector.js +0 -126
- package/src/core/biomeMigration.js +0 -22
- package/src/core/biomes/aiml.js +0 -36
- package/src/core/biomes/backend.js +0 -35
- package/src/core/biomes/docs.js +0 -35
- package/src/core/biomes/frontend.js +0 -35
- package/src/core/biomes/general.js +0 -34
- package/src/core/biomes/index.js +0 -54
- package/src/core/biomes/infra.js +0 -35
- package/src/core/environment.js +0 -335
- package/src/core/progression.js +0 -171
- package/src/core/sessionDetector.js +0 -92
- package/src/core/sprites.js +0 -1380
- package/src/core/state.js +0 -217
- package/src/renderers/terminal.js +0 -573
- package/src/tracker/activity.js +0 -43
- package/src/tracker/files.js +0 -44
- package/src/tracker/git.js +0 -77
package/src/plant.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { getSprite, TREE_TYPES } from "./sprites.js";
|
|
2
|
+
import { createEmptyForest, readForest, writeForest } from "./state.js";
|
|
3
|
+
import { findBadgeFile, writeBadgeSVG } from "./badge.js";
|
|
4
|
+
import { migrateLayout } from "./migrate.js";
|
|
5
|
+
|
|
6
|
+
const MIN_GAP = 6;
|
|
7
|
+
const DEFAULT_WIDTH = 80;
|
|
8
|
+
const TREE_SPACING = 6;
|
|
9
|
+
|
|
10
|
+
export function getVirtualWidth(treeCount, termWidth) {
|
|
11
|
+
return Math.max(termWidth, treeCount * TREE_SPACING);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPlantWidth(forest) {
|
|
15
|
+
const termWidth = forest.viewerWidth && forest.viewerWidth > 40
|
|
16
|
+
? forest.viewerWidth
|
|
17
|
+
: DEFAULT_WIDTH;
|
|
18
|
+
const treeCount = forest.trees.length + 1;
|
|
19
|
+
return getVirtualWidth(treeCount, termWidth);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function randomItem(items) {
|
|
23
|
+
return items[Math.floor(Math.random() * items.length)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function randomGrowth() {
|
|
27
|
+
return Math.round((0.3 + Math.random() * 0.7) * 100) / 100;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function occupiedRanges(trees) {
|
|
31
|
+
return trees.map((tree) => {
|
|
32
|
+
const sprite = getSprite(tree.type, tree.growth);
|
|
33
|
+
const half = Math.floor(sprite.width / 2);
|
|
34
|
+
return [tree.x - half - MIN_GAP, tree.x + half + MIN_GAP];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findOpenX(trees, type, growth, width) {
|
|
39
|
+
const sprite = getSprite(type, growth);
|
|
40
|
+
const half = Math.floor(sprite.width / 2);
|
|
41
|
+
const margin = half + 1;
|
|
42
|
+
const ranges = occupiedRanges(trees);
|
|
43
|
+
|
|
44
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
45
|
+
const x =
|
|
46
|
+
margin + Math.floor(Math.random() * Math.max(1, width - margin * 2));
|
|
47
|
+
const left = x - half;
|
|
48
|
+
const right = x + half;
|
|
49
|
+
const collides = ranges.some(
|
|
50
|
+
([occupiedLeft, occupiedRight]) =>
|
|
51
|
+
left < occupiedRight && right > occupiedLeft,
|
|
52
|
+
);
|
|
53
|
+
if (!collides) return x;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return margin + Math.floor(Math.random() * Math.max(1, width - margin * 2));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function nudgeGrowth(growth) {
|
|
60
|
+
if (growth >= 1) return 1;
|
|
61
|
+
const nextGrowth = growth + 0.1 + Math.random() * 0.1;
|
|
62
|
+
return Math.min(1, Math.round(nextGrowth * 100) / 100);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function daysBetween(dateA, dateB) {
|
|
66
|
+
const a = new Date(dateA + "T00:00:00");
|
|
67
|
+
const b = new Date(dateB + "T00:00:00");
|
|
68
|
+
return Math.round(Math.abs(b - a) / (24 * 60 * 60 * 1000));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function plant() {
|
|
72
|
+
const forest = readForest() ?? createEmptyForest();
|
|
73
|
+
const width = getPlantWidth(forest);
|
|
74
|
+
|
|
75
|
+
// Migrate old layouts to use virtual width
|
|
76
|
+
if (!forest.layoutVersion || forest.layoutVersion < 2) {
|
|
77
|
+
const termWidth = forest.viewerWidth && forest.viewerWidth > 40
|
|
78
|
+
? forest.viewerWidth
|
|
79
|
+
: DEFAULT_WIDTH;
|
|
80
|
+
migrateLayout(forest, termWidth);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Update streak
|
|
84
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
85
|
+
if (forest.lastActiveDate) {
|
|
86
|
+
const gap = daysBetween(forest.lastActiveDate, today);
|
|
87
|
+
if (gap === 0) {
|
|
88
|
+
// Same day — streak stays (ensure at least 1)
|
|
89
|
+
forest.streak = Math.max(forest.streak || 0, 1);
|
|
90
|
+
} else if (gap === 1) {
|
|
91
|
+
forest.streak = (forest.streak || 1) + 1;
|
|
92
|
+
} else {
|
|
93
|
+
forest.streak = 1;
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
forest.streak = 1;
|
|
97
|
+
}
|
|
98
|
+
forest.lastActiveDate = today;
|
|
99
|
+
|
|
100
|
+
for (const tree of forest.trees) {
|
|
101
|
+
tree.growth = nudgeGrowth(tree.growth);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const type = randomItem(TREE_TYPES);
|
|
105
|
+
const growth = randomGrowth();
|
|
106
|
+
const nextId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0) + 1;
|
|
107
|
+
|
|
108
|
+
forest.trees.push({
|
|
109
|
+
id: nextId,
|
|
110
|
+
type,
|
|
111
|
+
growth,
|
|
112
|
+
x: findOpenX(forest.trees, type, growth, width),
|
|
113
|
+
plantedAt: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
forest.totalPrompts += 1;
|
|
116
|
+
|
|
117
|
+
writeForest(forest);
|
|
118
|
+
|
|
119
|
+
// Auto-refresh badge if one exists in the repo
|
|
120
|
+
try {
|
|
121
|
+
const badgePath = findBadgeFile();
|
|
122
|
+
if (badgePath) writeBadgeSVG(forest, badgePath);
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
package/src/renderer.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
import { getSprite, getGroundDetail, TREE_TYPES, GROUND_DETAIL_TYPES } from "./sprites.js";
|
|
4
|
+
import { getVirtualWidth } from "./plant.js";
|
|
5
|
+
|
|
6
|
+
const SKY_ROWS = 4;
|
|
7
|
+
const TREE_ROWS = 10;
|
|
8
|
+
const GROUND_ROWS = 2;
|
|
9
|
+
const SPACER_ROWS = 1;
|
|
10
|
+
const STATS_ROWS = 1;
|
|
11
|
+
const CTA_ROWS = 1;
|
|
12
|
+
|
|
13
|
+
export const SCENE_HEIGHT =
|
|
14
|
+
SKY_ROWS + TREE_ROWS + GROUND_ROWS + SPACER_ROWS + STATS_ROWS + CTA_ROWS;
|
|
15
|
+
|
|
16
|
+
const STATS_ACCENT = "#f5a50b";
|
|
17
|
+
const STATS_TEXT = "#8e8a84";
|
|
18
|
+
const STATS_WARN = "#c4653a";
|
|
19
|
+
const STREAK_COLOR = "#e8a33a";
|
|
20
|
+
const BAR_FILL = "#6cb95e";
|
|
21
|
+
const BAR_EMPTY = "#3d3d3d";
|
|
22
|
+
const MILESTONES = [10, 25, 50, 100, 250, 500, 1000];
|
|
23
|
+
|
|
24
|
+
// Wilting — lerp toward dry brown when idle
|
|
25
|
+
const WILT_TARGET = { r: 0x8a, g: 0x6a, b: 0x4a };
|
|
26
|
+
|
|
27
|
+
function parseHex(hex) {
|
|
28
|
+
const h = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
29
|
+
return {
|
|
30
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
31
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
32
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toHex({ r, g, b }) {
|
|
37
|
+
const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
|
|
38
|
+
return `#${c(r)}${c(g)}${c(b)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function wiltColor(hex, factor) {
|
|
42
|
+
if (factor <= 0) return hex;
|
|
43
|
+
const c = parseHex(hex);
|
|
44
|
+
return toHex({
|
|
45
|
+
r: c.r + (WILT_TARGET.r - c.r) * factor,
|
|
46
|
+
g: c.g + (WILT_TARGET.g - c.g) * factor,
|
|
47
|
+
b: c.b + (WILT_TARGET.b - c.b) * factor,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getWiltFactor(lastActiveDate) {
|
|
52
|
+
if (!lastActiveDate) return 0;
|
|
53
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
54
|
+
const a = new Date(lastActiveDate + "T00:00:00");
|
|
55
|
+
const b = new Date(today + "T00:00:00");
|
|
56
|
+
const days = Math.round((b - a) / (24 * 60 * 60 * 1000));
|
|
57
|
+
if (days <= 0) return 0;
|
|
58
|
+
if (days === 1) return 0.25;
|
|
59
|
+
if (days === 2) return 0.45;
|
|
60
|
+
if (days === 3) return 0.65;
|
|
61
|
+
return Math.min(0.85, 0.65 + (days - 3) * 0.05);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fog — procedural haze that thickens with inactivity
|
|
65
|
+
const FOG_CHARS = ["░", "░", "▒"];
|
|
66
|
+
const FOG_COLOR_UPPER = "#9a9a9a";
|
|
67
|
+
const FOG_COLOR_LOWER = "#6a6a6a";
|
|
68
|
+
|
|
69
|
+
function applyFog(buffer, wilt, width) {
|
|
70
|
+
if (wilt <= 0) return;
|
|
71
|
+
// Higher wilt → lower threshold → more fog
|
|
72
|
+
const threshold = Math.max(3, Math.round(18 * (1 - wilt)));
|
|
73
|
+
const fogStart = SKY_ROWS - 2; // creep into lower sky
|
|
74
|
+
const fogEnd = SKY_ROWS + TREE_ROWS + GROUND_ROWS;
|
|
75
|
+
|
|
76
|
+
for (let y = Math.max(0, fogStart); y < fogEnd; y += 1) {
|
|
77
|
+
for (let x = 0; x < width; x += 1) {
|
|
78
|
+
const h = hash(x * 31 + y * 97 + 12345);
|
|
79
|
+
if (h % threshold !== 0) continue;
|
|
80
|
+
const fogChar = FOG_CHARS[h % FOG_CHARS.length];
|
|
81
|
+
const blend = (y - fogStart) / (fogEnd - fogStart);
|
|
82
|
+
const fogColor = blend > 0.5 ? FOG_COLOR_LOWER : FOG_COLOR_UPPER;
|
|
83
|
+
buffer[y][x] = { char: fogChar, color: fogColor };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Biomes evolve as the forest grows — never resets, only gets richer
|
|
89
|
+
const BIOMES = [
|
|
90
|
+
{ // 0-9: sparse clearing
|
|
91
|
+
ground: ["#2a3a28", "#1e2d1c"],
|
|
92
|
+
starGlyphs: ["·", ".", " ", " "],
|
|
93
|
+
starDensity: 12,
|
|
94
|
+
starColors: ["#3a3a3a", "#444444"],
|
|
95
|
+
label: "clearing",
|
|
96
|
+
detailDensity: 0,
|
|
97
|
+
detailTypes: [],
|
|
98
|
+
},
|
|
99
|
+
{ // 10-24: young grove
|
|
100
|
+
ground: ["#22492d", "#18361f"],
|
|
101
|
+
starGlyphs: ["·", "·", "✦", "."],
|
|
102
|
+
starDensity: 9,
|
|
103
|
+
starColors: ["#444444", "#5d5d5d"],
|
|
104
|
+
label: "grove",
|
|
105
|
+
detailDensity: 18,
|
|
106
|
+
detailTypes: ["rock", "grass"],
|
|
107
|
+
},
|
|
108
|
+
{ // 25-49: dense woodland
|
|
109
|
+
ground: ["#1e4a28", "#163a1e"],
|
|
110
|
+
starGlyphs: ["·", "✦", "✧", "·", "."],
|
|
111
|
+
starDensity: 7,
|
|
112
|
+
starColors: ["#4d4d4d", "#5d5d5d", "#6a6a55"],
|
|
113
|
+
label: "woodland",
|
|
114
|
+
detailDensity: 12,
|
|
115
|
+
detailTypes: ["rock", "grass", "mushroom", "bush"],
|
|
116
|
+
},
|
|
117
|
+
{ // 50-99: old growth
|
|
118
|
+
ground: ["#1a5230", "#124020"],
|
|
119
|
+
starGlyphs: ["✦", "✧", "·", "·", "✦", "."],
|
|
120
|
+
starDensity: 6,
|
|
121
|
+
starColors: ["#5d5d5d", "#6d6d5a", "#7a7a60"],
|
|
122
|
+
label: "old growth",
|
|
123
|
+
detailDensity: 8,
|
|
124
|
+
detailTypes: ["rock", "grass", "mushroom", "bush", "leaf"],
|
|
125
|
+
},
|
|
126
|
+
{ // 100+: ancient forest
|
|
127
|
+
ground: ["#165a32", "#0e4822"],
|
|
128
|
+
starGlyphs: ["✦", "✧", "·", "✦", "⋆", "."],
|
|
129
|
+
starDensity: 5,
|
|
130
|
+
starColors: ["#6d6d5a", "#7a7a60", "#8a8a6a"],
|
|
131
|
+
label: "ancient forest",
|
|
132
|
+
detailDensity: 5,
|
|
133
|
+
detailTypes: ["rock", "grass", "mushroom", "bush", "leaf"],
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
function getBiome(treeCount) {
|
|
138
|
+
if (treeCount < 10) return BIOMES[0];
|
|
139
|
+
if (treeCount < 25) return BIOMES[1];
|
|
140
|
+
if (treeCount < 50) return BIOMES[2];
|
|
141
|
+
if (treeCount < 100) return BIOMES[3];
|
|
142
|
+
return BIOMES[4];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function createBuffer(width) {
|
|
146
|
+
return Array.from({ length: SCENE_HEIGHT }, () =>
|
|
147
|
+
Array.from({ length: width }, () => ({ char: " ", color: null })),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hash(seed) {
|
|
152
|
+
let value = seed >>> 0;
|
|
153
|
+
value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
|
|
154
|
+
value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
|
|
155
|
+
return ((value >>> 16) ^ value) >>> 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getTreeYOffset(treeId) {
|
|
159
|
+
const h = hash(treeId * 13 + 7);
|
|
160
|
+
return h % 2; // Returns 0 or 1 (only up, never below ground)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function generateStars(width, biome, twinkle = 0) {
|
|
164
|
+
const stars = [];
|
|
165
|
+
for (let x = 0; x < width; x += 1) {
|
|
166
|
+
const seeded = hash(x + width * 17 + twinkle * 101);
|
|
167
|
+
if (seeded % biome.starDensity !== 0) continue;
|
|
168
|
+
stars.push({
|
|
169
|
+
x,
|
|
170
|
+
y: seeded % SKY_ROWS,
|
|
171
|
+
char: biome.starGlyphs[seeded % biome.starGlyphs.length],
|
|
172
|
+
color: biome.starColors[seeded % biome.starColors.length],
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return stars;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function compositeSprite(buffer, sprite, centerX, baseY) {
|
|
179
|
+
const offsetX = centerX - Math.floor(sprite.width / 2);
|
|
180
|
+
for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex += 1) {
|
|
181
|
+
const targetY = baseY - rowIndex;
|
|
182
|
+
if (targetY < 0 || targetY >= buffer.length) continue;
|
|
183
|
+
const row = sprite.rows[rowIndex];
|
|
184
|
+
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
|
|
185
|
+
const targetX = offsetX + columnIndex;
|
|
186
|
+
if (targetX < 0 || targetX >= buffer[0].length) continue;
|
|
187
|
+
const [char, color] = row[columnIndex];
|
|
188
|
+
if (!color) continue;
|
|
189
|
+
buffer[targetY][targetX] = { char, color };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderGroundDetails(buffer, biome, virtualWidth, groundStart) {
|
|
195
|
+
if (biome.detailDensity === 0 || biome.detailTypes.length === 0) return;
|
|
196
|
+
|
|
197
|
+
const detailRow = groundStart - 1; // lowest tree row, just above ground
|
|
198
|
+
|
|
199
|
+
for (let x = 0; x < virtualWidth; x += 1) {
|
|
200
|
+
const h = hash(x * 53 + 9973);
|
|
201
|
+
if (h % biome.detailDensity !== 0) continue;
|
|
202
|
+
|
|
203
|
+
// Pick detail type deterministically
|
|
204
|
+
const detailType = biome.detailTypes[h % biome.detailTypes.length];
|
|
205
|
+
const sprite = getGroundDetail(detailType);
|
|
206
|
+
|
|
207
|
+
// Only place if all cells are currently empty (no tree pixel there)
|
|
208
|
+
// compositeSprite centers the sprite, so match that logic here
|
|
209
|
+
const offsetX = x - Math.floor(sprite.width / 2);
|
|
210
|
+
let blocked = false;
|
|
211
|
+
for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex++) {
|
|
212
|
+
const targetY = detailRow - rowIndex;
|
|
213
|
+
if (targetY < 0 || targetY >= buffer.length) { blocked = true; break; }
|
|
214
|
+
for (let colIndex = 0; colIndex < sprite.rows[rowIndex].length; colIndex++) {
|
|
215
|
+
const targetX = offsetX + colIndex;
|
|
216
|
+
if (targetX < 0 || targetX >= virtualWidth) { blocked = true; break; }
|
|
217
|
+
const [, color] = sprite.rows[rowIndex][colIndex];
|
|
218
|
+
if (color && buffer[targetY][targetX].color) { blocked = true; break; }
|
|
219
|
+
}
|
|
220
|
+
if (blocked) break;
|
|
221
|
+
}
|
|
222
|
+
if (blocked) continue;
|
|
223
|
+
|
|
224
|
+
// Place the detail sprite (compositeSprite centers at x)
|
|
225
|
+
compositeSprite(buffer, sprite, x, detailRow);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getNextMilestone(treeCount) {
|
|
230
|
+
return MILESTONES.find((value) => treeCount < value) ?? treeCount + 100;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getNextTreeType(treeCount) {
|
|
234
|
+
return TREE_TYPES[treeCount % TREE_TYPES.length];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildStreakSegment(forest) {
|
|
238
|
+
const wilt = getWiltFactor(forest.lastActiveDate);
|
|
239
|
+
const streak = forest.streak || 0;
|
|
240
|
+
|
|
241
|
+
if (wilt > 0) {
|
|
242
|
+
const a = new Date(forest.lastActiveDate + "T00:00:00");
|
|
243
|
+
const b = new Date(new Date().toISOString().slice(0, 10) + "T00:00:00");
|
|
244
|
+
const idle = Math.round((b - a) / (24 * 60 * 60 * 1000));
|
|
245
|
+
return chalk.hex(STATS_WARN)(`wilting (${idle}d idle)`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (streak <= 0) return chalk.hex(STATS_TEXT)("no streak");
|
|
249
|
+
return chalk.hex(STREAK_COLOR)(`${streak}-day streak`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function buildStatsLine(forest, biome, viewportX = 0, virtualWidth = 0, termWidth = 80) {
|
|
253
|
+
const treeCount = forest.trees.length;
|
|
254
|
+
const milestone = getNextMilestone(treeCount);
|
|
255
|
+
const progress = milestone === 0 ? 0 : treeCount / milestone;
|
|
256
|
+
const barWidth = 12;
|
|
257
|
+
const filledWidth = Math.max(0, Math.min(barWidth, Math.round(progress * barWidth)));
|
|
258
|
+
const bar =
|
|
259
|
+
chalk.hex(BAR_FILL)("█".repeat(filledWidth)) +
|
|
260
|
+
chalk.hex(BAR_EMPTY)("░".repeat(barWidth - filledWidth));
|
|
261
|
+
|
|
262
|
+
// Viewport minimap — only show when forest is wider than terminal
|
|
263
|
+
let minimap = "";
|
|
264
|
+
if (virtualWidth > termWidth) {
|
|
265
|
+
const mapWidth = 12;
|
|
266
|
+
const viewFraction = termWidth / virtualWidth;
|
|
267
|
+
const thumbWidth = Math.max(1, Math.round(viewFraction * mapWidth));
|
|
268
|
+
const maxOffset = virtualWidth - termWidth;
|
|
269
|
+
const thumbPos = maxOffset > 0
|
|
270
|
+
? Math.round((viewportX / maxOffset) * (mapWidth - thumbWidth))
|
|
271
|
+
: 0;
|
|
272
|
+
const mapBar =
|
|
273
|
+
"─".repeat(thumbPos) +
|
|
274
|
+
"═".repeat(thumbWidth) +
|
|
275
|
+
"─".repeat(mapWidth - thumbPos - thumbWidth);
|
|
276
|
+
minimap = chalk.hex(STATS_TEXT)(" [") +
|
|
277
|
+
chalk.hex(BAR_FILL)(mapBar) +
|
|
278
|
+
chalk.hex(STATS_TEXT)("]");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
chalk.hex(STATS_ACCENT)(" honeytree") +
|
|
283
|
+
chalk.hex(STATS_TEXT)(
|
|
284
|
+
` · ${treeCount} tree${treeCount === 1 ? "" : "s"} · `,
|
|
285
|
+
) +
|
|
286
|
+
buildStreakSegment(forest) +
|
|
287
|
+
chalk.hex(STATS_TEXT)(" · ") +
|
|
288
|
+
bar +
|
|
289
|
+
chalk.hex(STATS_TEXT)(` next: ${getNextTreeType(treeCount)}`) +
|
|
290
|
+
chalk.hex("#555555")(` [${biome.label}]`) +
|
|
291
|
+
minimap
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
296
|
+
const width = Math.max(40, termWidth);
|
|
297
|
+
const treeCount = forest.trees.length;
|
|
298
|
+
const virtualWidth = options.virtualWidth ?? getVirtualWidth(treeCount, width);
|
|
299
|
+
const viewportX = Math.max(
|
|
300
|
+
0,
|
|
301
|
+
Math.min(options.viewportX ?? 0, Math.max(0, virtualWidth - width)),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// Build the full virtual-width buffer
|
|
305
|
+
const buffer = createBuffer(virtualWidth);
|
|
306
|
+
const groundStart = SKY_ROWS + TREE_ROWS;
|
|
307
|
+
const biome = getBiome(treeCount);
|
|
308
|
+
const wilt = getWiltFactor(forest.lastActiveDate);
|
|
309
|
+
|
|
310
|
+
for (const star of generateStars(virtualWidth, biome, options.twinkleSeed ?? 0)) {
|
|
311
|
+
buffer[star.y][star.x] = { char: star.char, color: star.color };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
|
|
315
|
+
for (let x = 0; x < virtualWidth; x += 1) {
|
|
316
|
+
buffer[groundStart + rowIndex][x] = {
|
|
317
|
+
char: "█",
|
|
318
|
+
color: biome.ground[rowIndex],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const treeBaseY = groundStart - 1;
|
|
324
|
+
for (const tree of forest.trees) {
|
|
325
|
+
const yOffset = getTreeYOffset(tree.id);
|
|
326
|
+
compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
renderGroundDetails(buffer, biome, virtualWidth, groundStart);
|
|
330
|
+
|
|
331
|
+
applyFog(buffer, wilt, virtualWidth);
|
|
332
|
+
|
|
333
|
+
// Slice the viewport from the virtual buffer
|
|
334
|
+
const lines = [];
|
|
335
|
+
for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS - CTA_ROWS; y += 1) {
|
|
336
|
+
let line = "";
|
|
337
|
+
for (let x = viewportX; x < viewportX + width; x += 1) {
|
|
338
|
+
const cell = buffer[y][x];
|
|
339
|
+
if (!cell.color) {
|
|
340
|
+
line += cell.char;
|
|
341
|
+
} else {
|
|
342
|
+
const color = wilt > 0 && y >= SKY_ROWS ? wiltColor(cell.color, wilt) : cell.color;
|
|
343
|
+
line += chalk.hex(color)(cell.char);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
lines.push(line);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
lines.push("");
|
|
350
|
+
lines.push(buildStatsLine(forest, biome, viewportX, virtualWidth, width));
|
|
351
|
+
lines.push(
|
|
352
|
+
chalk.hex("#555555")(" ← → pan · add your forest to your README → ") +
|
|
353
|
+
chalk.hex(STATS_ACCENT)("honeytree badge"),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
return lines.join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function buildScene(forest, width) {
|
|
360
|
+
const w = Math.max(40, width);
|
|
361
|
+
const sceneRows = SKY_ROWS + TREE_ROWS + GROUND_ROWS;
|
|
362
|
+
const buffer = Array.from({ length: sceneRows }, () =>
|
|
363
|
+
Array.from({ length: w }, () => ({ char: " ", color: null })),
|
|
364
|
+
);
|
|
365
|
+
const groundStart = SKY_ROWS + TREE_ROWS;
|
|
366
|
+
const biome = getBiome(forest.trees.length);
|
|
367
|
+
const wilt = getWiltFactor(forest.lastActiveDate);
|
|
368
|
+
|
|
369
|
+
for (const star of generateStars(w, biome, 0)) {
|
|
370
|
+
if (star.y < sceneRows) {
|
|
371
|
+
buffer[star.y][star.x] = { char: star.char, color: star.color };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
|
|
376
|
+
for (let x = 0; x < w; x += 1) {
|
|
377
|
+
buffer[groundStart + rowIndex][x] = { char: "█", color: biome.ground[rowIndex] };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const treeBaseY = groundStart - 1;
|
|
382
|
+
for (const tree of forest.trees) {
|
|
383
|
+
const yOffset = getTreeYOffset(tree.id);
|
|
384
|
+
compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
renderGroundDetails(buffer, biome, w, groundStart);
|
|
388
|
+
|
|
389
|
+
applyFog(buffer, wilt, w);
|
|
390
|
+
|
|
391
|
+
if (wilt > 0) {
|
|
392
|
+
for (let y = SKY_ROWS; y < sceneRows; y += 1) {
|
|
393
|
+
for (let x = 0; x < w; x += 1) {
|
|
394
|
+
if (buffer[y][x].color) {
|
|
395
|
+
buffer[y][x].color = wiltColor(buffer[y][x].color, wilt);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { buffer, biome, sceneRows };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function renderPlainText(forest, width = 60) {
|
|
405
|
+
const w = Math.max(40, Math.min(width, 80));
|
|
406
|
+
const buffer = createBuffer(w);
|
|
407
|
+
const groundStart = SKY_ROWS + TREE_ROWS;
|
|
408
|
+
const biome = getBiome(forest.trees.length);
|
|
409
|
+
|
|
410
|
+
for (const star of generateStars(w, biome, 0)) {
|
|
411
|
+
buffer[star.y][star.x] = { char: star.char, color: star.color };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
|
|
415
|
+
for (let x = 0; x < w; x += 1) {
|
|
416
|
+
buffer[groundStart + rowIndex][x] = { char: "█", color: "#333" };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const treeBaseY = groundStart - 1;
|
|
421
|
+
for (const tree of forest.trees) {
|
|
422
|
+
const yOffset = getTreeYOffset(tree.id);
|
|
423
|
+
compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const lines = [];
|
|
427
|
+
for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS - CTA_ROWS; y += 1) {
|
|
428
|
+
let line = "";
|
|
429
|
+
for (const cell of buffer[y]) {
|
|
430
|
+
line += cell.char;
|
|
431
|
+
}
|
|
432
|
+
lines.push(line.trimEnd());
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return lines.join("\n");
|
|
436
|
+
}
|