honeytree 1.1.5 → 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/package.json +3 -2
- package/src/migrate.js +47 -0
- package/src/plant.js +20 -4
- package/src/renderer.js +103 -16
- package/src/sprites.js +57 -0
- package/src/viewer.js +96 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "honeytree",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "Grow a forest in your terminal every time you use Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"author": "Varun Nukala",
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"chalk": "^5.4.1"
|
|
36
|
+
"chalk": "^5.4.1",
|
|
37
|
+
"honeytree": "^1.1.5"
|
|
37
38
|
},
|
|
38
39
|
"engines": {
|
|
39
40
|
"node": ">=18"
|
package/src/migrate.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getVirtualWidth } from "./plant.js";
|
|
2
|
+
|
|
3
|
+
function hash(seed) {
|
|
4
|
+
let value = seed >>> 0;
|
|
5
|
+
value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
|
|
6
|
+
value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
|
|
7
|
+
return ((value >>> 16) ^ value) >>> 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function migrateLayout(forest, termWidth) {
|
|
11
|
+
if (forest.layoutVersion >= 2) return forest;
|
|
12
|
+
|
|
13
|
+
const trees = forest.trees;
|
|
14
|
+
if (trees.length === 0) {
|
|
15
|
+
forest.layoutVersion = 2;
|
|
16
|
+
return forest;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const virtualWidth = getVirtualWidth(trees.length, termWidth);
|
|
20
|
+
const margin = 6;
|
|
21
|
+
const usable = virtualWidth - margin * 2;
|
|
22
|
+
|
|
23
|
+
// Sort by current x to preserve left-to-right order
|
|
24
|
+
const sorted = [...trees].sort((a, b) => a.x - b.x);
|
|
25
|
+
|
|
26
|
+
// Spread evenly across usable width with deterministic jitter
|
|
27
|
+
const gap = trees.length === 1 ? 0 : usable / (trees.length - 1);
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
30
|
+
const baseX = trees.length === 1
|
|
31
|
+
? Math.round(virtualWidth / 2)
|
|
32
|
+
: Math.round(margin + i * gap);
|
|
33
|
+
// Deterministic jitter: +/-2 based on tree id
|
|
34
|
+
const jitter = (hash(sorted[i].id * 7 + 31) % 5) - 2;
|
|
35
|
+
sorted[i].x = Math.max(margin, Math.min(virtualWidth - margin, baseX + jitter));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Ensure order is preserved after jitter — nudge if needed
|
|
39
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
40
|
+
if (sorted[i].x <= sorted[i - 1].x) {
|
|
41
|
+
sorted[i].x = sorted[i - 1].x + 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
forest.layoutVersion = 2;
|
|
46
|
+
return forest;
|
|
47
|
+
}
|
package/src/plant.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { getSprite, TREE_TYPES } from "./sprites.js";
|
|
2
2
|
import { createEmptyForest, readForest, writeForest } from "./state.js";
|
|
3
3
|
import { findBadgeFile, writeBadgeSVG } from "./badge.js";
|
|
4
|
+
import { migrateLayout } from "./migrate.js";
|
|
4
5
|
|
|
5
|
-
const MIN_GAP =
|
|
6
|
+
const MIN_GAP = 6;
|
|
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
|
+
}
|
|
7
13
|
|
|
8
14
|
function getPlantWidth(forest) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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);
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
function randomItem(items) {
|
|
@@ -64,6 +72,14 @@ export async function plant() {
|
|
|
64
72
|
const forest = readForest() ?? createEmptyForest();
|
|
65
73
|
const width = getPlantWidth(forest);
|
|
66
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
|
+
|
|
67
83
|
// Update streak
|
|
68
84
|
const today = new Date().toISOString().slice(0, 10);
|
|
69
85
|
if (forest.lastActiveDate) {
|
package/src/renderer.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
|
|
3
|
-
import { getSprite, TREE_TYPES } from "./sprites.js";
|
|
3
|
+
import { getSprite, getGroundDetail, TREE_TYPES, GROUND_DETAIL_TYPES } from "./sprites.js";
|
|
4
|
+
import { getVirtualWidth } from "./plant.js";
|
|
4
5
|
|
|
5
6
|
const SKY_ROWS = 4;
|
|
6
|
-
const TREE_ROWS =
|
|
7
|
+
const TREE_ROWS = 10;
|
|
7
8
|
const GROUND_ROWS = 2;
|
|
8
9
|
const SPACER_ROWS = 1;
|
|
9
10
|
const STATS_ROWS = 1;
|
|
@@ -92,6 +93,8 @@ const BIOMES = [
|
|
|
92
93
|
starDensity: 12,
|
|
93
94
|
starColors: ["#3a3a3a", "#444444"],
|
|
94
95
|
label: "clearing",
|
|
96
|
+
detailDensity: 0,
|
|
97
|
+
detailTypes: [],
|
|
95
98
|
},
|
|
96
99
|
{ // 10-24: young grove
|
|
97
100
|
ground: ["#22492d", "#18361f"],
|
|
@@ -99,6 +102,8 @@ const BIOMES = [
|
|
|
99
102
|
starDensity: 9,
|
|
100
103
|
starColors: ["#444444", "#5d5d5d"],
|
|
101
104
|
label: "grove",
|
|
105
|
+
detailDensity: 18,
|
|
106
|
+
detailTypes: ["rock", "grass"],
|
|
102
107
|
},
|
|
103
108
|
{ // 25-49: dense woodland
|
|
104
109
|
ground: ["#1e4a28", "#163a1e"],
|
|
@@ -106,6 +111,8 @@ const BIOMES = [
|
|
|
106
111
|
starDensity: 7,
|
|
107
112
|
starColors: ["#4d4d4d", "#5d5d5d", "#6a6a55"],
|
|
108
113
|
label: "woodland",
|
|
114
|
+
detailDensity: 12,
|
|
115
|
+
detailTypes: ["rock", "grass", "mushroom", "bush"],
|
|
109
116
|
},
|
|
110
117
|
{ // 50-99: old growth
|
|
111
118
|
ground: ["#1a5230", "#124020"],
|
|
@@ -113,6 +120,8 @@ const BIOMES = [
|
|
|
113
120
|
starDensity: 6,
|
|
114
121
|
starColors: ["#5d5d5d", "#6d6d5a", "#7a7a60"],
|
|
115
122
|
label: "old growth",
|
|
123
|
+
detailDensity: 8,
|
|
124
|
+
detailTypes: ["rock", "grass", "mushroom", "bush", "leaf"],
|
|
116
125
|
},
|
|
117
126
|
{ // 100+: ancient forest
|
|
118
127
|
ground: ["#165a32", "#0e4822"],
|
|
@@ -120,6 +129,8 @@ const BIOMES = [
|
|
|
120
129
|
starDensity: 5,
|
|
121
130
|
starColors: ["#6d6d5a", "#7a7a60", "#8a8a6a"],
|
|
122
131
|
label: "ancient forest",
|
|
132
|
+
detailDensity: 5,
|
|
133
|
+
detailTypes: ["rock", "grass", "mushroom", "bush", "leaf"],
|
|
123
134
|
},
|
|
124
135
|
];
|
|
125
136
|
|
|
@@ -144,6 +155,11 @@ function hash(seed) {
|
|
|
144
155
|
return ((value >>> 16) ^ value) >>> 0;
|
|
145
156
|
}
|
|
146
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
|
+
|
|
147
163
|
function generateStars(width, biome, twinkle = 0) {
|
|
148
164
|
const stars = [];
|
|
149
165
|
for (let x = 0; x < width; x += 1) {
|
|
@@ -175,6 +191,41 @@ function compositeSprite(buffer, sprite, centerX, baseY) {
|
|
|
175
191
|
}
|
|
176
192
|
}
|
|
177
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
|
+
|
|
178
229
|
function getNextMilestone(treeCount) {
|
|
179
230
|
return MILESTONES.find((value) => treeCount < value) ?? treeCount + 100;
|
|
180
231
|
}
|
|
@@ -198,7 +249,7 @@ function buildStreakSegment(forest) {
|
|
|
198
249
|
return chalk.hex(STREAK_COLOR)(`${streak}-day streak`);
|
|
199
250
|
}
|
|
200
251
|
|
|
201
|
-
function buildStatsLine(forest, biome) {
|
|
252
|
+
function buildStatsLine(forest, biome, viewportX = 0, virtualWidth = 0, termWidth = 80) {
|
|
202
253
|
const treeCount = forest.trees.length;
|
|
203
254
|
const milestone = getNextMilestone(treeCount);
|
|
204
255
|
const progress = milestone === 0 ? 0 : treeCount / milestone;
|
|
@@ -208,6 +259,25 @@ function buildStatsLine(forest, biome) {
|
|
|
208
259
|
chalk.hex(BAR_FILL)("█".repeat(filledWidth)) +
|
|
209
260
|
chalk.hex(BAR_EMPTY)("░".repeat(barWidth - filledWidth));
|
|
210
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
|
+
|
|
211
281
|
return (
|
|
212
282
|
chalk.hex(STATS_ACCENT)(" honeytree") +
|
|
213
283
|
chalk.hex(STATS_TEXT)(
|
|
@@ -217,23 +287,32 @@ function buildStatsLine(forest, biome) {
|
|
|
217
287
|
chalk.hex(STATS_TEXT)(" · ") +
|
|
218
288
|
bar +
|
|
219
289
|
chalk.hex(STATS_TEXT)(` next: ${getNextTreeType(treeCount)}`) +
|
|
220
|
-
chalk.hex("#555555")(` [${biome.label}]`)
|
|
290
|
+
chalk.hex("#555555")(` [${biome.label}]`) +
|
|
291
|
+
minimap
|
|
221
292
|
);
|
|
222
293
|
}
|
|
223
294
|
|
|
224
295
|
export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
225
296
|
const width = Math.max(40, termWidth);
|
|
226
|
-
const
|
|
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);
|
|
227
306
|
const groundStart = SKY_ROWS + TREE_ROWS;
|
|
228
|
-
const biome = getBiome(
|
|
307
|
+
const biome = getBiome(treeCount);
|
|
229
308
|
const wilt = getWiltFactor(forest.lastActiveDate);
|
|
230
309
|
|
|
231
|
-
for (const star of generateStars(
|
|
310
|
+
for (const star of generateStars(virtualWidth, biome, options.twinkleSeed ?? 0)) {
|
|
232
311
|
buffer[star.y][star.x] = { char: star.char, color: star.color };
|
|
233
312
|
}
|
|
234
313
|
|
|
235
314
|
for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
|
|
236
|
-
for (let x = 0; x <
|
|
315
|
+
for (let x = 0; x < virtualWidth; x += 1) {
|
|
237
316
|
buffer[groundStart + rowIndex][x] = {
|
|
238
317
|
char: "█",
|
|
239
318
|
color: biome.ground[rowIndex],
|
|
@@ -243,19 +322,23 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
243
322
|
|
|
244
323
|
const treeBaseY = groundStart - 1;
|
|
245
324
|
for (const tree of forest.trees) {
|
|
246
|
-
|
|
325
|
+
const yOffset = getTreeYOffset(tree.id);
|
|
326
|
+
compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
|
|
247
327
|
}
|
|
248
328
|
|
|
249
|
-
|
|
329
|
+
renderGroundDetails(buffer, biome, virtualWidth, groundStart);
|
|
330
|
+
|
|
331
|
+
applyFog(buffer, wilt, virtualWidth);
|
|
250
332
|
|
|
333
|
+
// Slice the viewport from the virtual buffer
|
|
251
334
|
const lines = [];
|
|
252
335
|
for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS - CTA_ROWS; y += 1) {
|
|
253
336
|
let line = "";
|
|
254
|
-
for (
|
|
337
|
+
for (let x = viewportX; x < viewportX + width; x += 1) {
|
|
338
|
+
const cell = buffer[y][x];
|
|
255
339
|
if (!cell.color) {
|
|
256
340
|
line += cell.char;
|
|
257
341
|
} else {
|
|
258
|
-
// Apply wilting to tree rows and ground (skip sky)
|
|
259
342
|
const color = wilt > 0 && y >= SKY_ROWS ? wiltColor(cell.color, wilt) : cell.color;
|
|
260
343
|
line += chalk.hex(color)(cell.char);
|
|
261
344
|
}
|
|
@@ -264,9 +347,9 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
|
|
|
264
347
|
}
|
|
265
348
|
|
|
266
349
|
lines.push("");
|
|
267
|
-
lines.push(buildStatsLine(forest, biome));
|
|
350
|
+
lines.push(buildStatsLine(forest, biome, viewportX, virtualWidth, width));
|
|
268
351
|
lines.push(
|
|
269
|
-
chalk.hex("#555555")(" add your forest to your README → ") +
|
|
352
|
+
chalk.hex("#555555")(" ← → pan · add your forest to your README → ") +
|
|
270
353
|
chalk.hex(STATS_ACCENT)("honeytree badge"),
|
|
271
354
|
);
|
|
272
355
|
|
|
@@ -297,9 +380,12 @@ export function buildScene(forest, width) {
|
|
|
297
380
|
|
|
298
381
|
const treeBaseY = groundStart - 1;
|
|
299
382
|
for (const tree of forest.trees) {
|
|
300
|
-
|
|
383
|
+
const yOffset = getTreeYOffset(tree.id);
|
|
384
|
+
compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
|
|
301
385
|
}
|
|
302
386
|
|
|
387
|
+
renderGroundDetails(buffer, biome, w, groundStart);
|
|
388
|
+
|
|
303
389
|
applyFog(buffer, wilt, w);
|
|
304
390
|
|
|
305
391
|
if (wilt > 0) {
|
|
@@ -333,7 +419,8 @@ export function renderPlainText(forest, width = 60) {
|
|
|
333
419
|
|
|
334
420
|
const treeBaseY = groundStart - 1;
|
|
335
421
|
for (const tree of forest.trees) {
|
|
336
|
-
|
|
422
|
+
const yOffset = getTreeYOffset(tree.id);
|
|
423
|
+
compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
|
|
337
424
|
}
|
|
338
425
|
|
|
339
426
|
const lines = [];
|
package/src/sprites.js
CHANGED
|
@@ -14,6 +14,19 @@ const COLORS = {
|
|
|
14
14
|
cherryBloom: "#f0b7cf",
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
const DETAIL_COLORS = {
|
|
18
|
+
mushroom: "#c4a882",
|
|
19
|
+
mushroomCap: "#9e4a3a",
|
|
20
|
+
rock: "#6b6b6b",
|
|
21
|
+
rockLight: "#8a8a8a",
|
|
22
|
+
grass: "#4a7a3a",
|
|
23
|
+
grassLight: "#6ba85a",
|
|
24
|
+
leaf: "#8a6a3a",
|
|
25
|
+
leafDark: "#6a4a2a",
|
|
26
|
+
bush: "#3a6a2a",
|
|
27
|
+
bushLight: "#5a8a4a",
|
|
28
|
+
};
|
|
29
|
+
|
|
17
30
|
function parse(template, palette) {
|
|
18
31
|
const lines = template.trim().split("\n");
|
|
19
32
|
const width = Math.max(...lines.map((line) => line.length));
|
|
@@ -229,6 +242,50 @@ pPPpPPPp
|
|
|
229
242
|
},
|
|
230
243
|
};
|
|
231
244
|
|
|
245
|
+
const GROUND_DETAILS = {
|
|
246
|
+
mushroom: parse(
|
|
247
|
+
`
|
|
248
|
+
rr
|
|
249
|
+
t
|
|
250
|
+
`,
|
|
251
|
+
{ r: DETAIL_COLORS.mushroomCap, t: DETAIL_COLORS.mushroom },
|
|
252
|
+
),
|
|
253
|
+
rock: parse(
|
|
254
|
+
`
|
|
255
|
+
rR
|
|
256
|
+
`,
|
|
257
|
+
{ r: DETAIL_COLORS.rock, R: DETAIL_COLORS.rockLight },
|
|
258
|
+
),
|
|
259
|
+
grass: parse(
|
|
260
|
+
`
|
|
261
|
+
gG
|
|
262
|
+
`,
|
|
263
|
+
{ g: DETAIL_COLORS.grass, G: DETAIL_COLORS.grassLight },
|
|
264
|
+
),
|
|
265
|
+
leaf: parse(
|
|
266
|
+
`
|
|
267
|
+
lL
|
|
268
|
+
`,
|
|
269
|
+
{ l: DETAIL_COLORS.leaf, L: DETAIL_COLORS.leafDark },
|
|
270
|
+
),
|
|
271
|
+
bush: parse(
|
|
272
|
+
`
|
|
273
|
+
bB
|
|
274
|
+
`,
|
|
275
|
+
{ b: DETAIL_COLORS.bush, B: DETAIL_COLORS.bushLight },
|
|
276
|
+
),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export const GROUND_DETAIL_TYPES = Object.keys(GROUND_DETAILS);
|
|
280
|
+
|
|
281
|
+
export function getGroundDetail(type) {
|
|
282
|
+
const detail = GROUND_DETAILS[type];
|
|
283
|
+
if (!detail) {
|
|
284
|
+
throw new Error(`Unknown ground detail type: ${type}`);
|
|
285
|
+
}
|
|
286
|
+
return detail;
|
|
287
|
+
}
|
|
288
|
+
|
|
232
289
|
function getGrowthStage(growth) {
|
|
233
290
|
if (growth < 0.2) return "seed";
|
|
234
291
|
if (growth < 0.5) return "sapling";
|
package/src/viewer.js
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
import { renderFrame } from "./renderer.js";
|
|
4
4
|
import { getForestFile, readForest, writeForest } from "./state.js";
|
|
5
|
+
import { migrateLayout } from "./migrate.js";
|
|
6
|
+
import { getVirtualWidth } from "./plant.js";
|
|
5
7
|
|
|
6
8
|
function writeAnsi(code) {
|
|
7
9
|
process.stdout.write(code);
|
|
@@ -23,37 +25,10 @@ function moveHome() {
|
|
|
23
25
|
writeAnsi("\x1b[H");
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
function renderForest(forest, twinkleSeed = 0) {
|
|
27
|
-
moveHome();
|
|
28
|
-
process.stdout.write(renderFrame(forest, process.stdout.columns || 80, { twinkleSeed }));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
28
|
function delay(ms) {
|
|
32
29
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
30
|
}
|
|
34
31
|
|
|
35
|
-
async function animateNewTree(forest, newTreeId) {
|
|
36
|
-
const tree = forest.trees.find((entry) => entry.id === newTreeId);
|
|
37
|
-
if (!tree) {
|
|
38
|
-
renderForest(forest);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const originalGrowth = tree.growth;
|
|
43
|
-
const frames = [0.12, 0.32, 0.6, originalGrowth].filter(
|
|
44
|
-
(value, index, values) => value <= originalGrowth && values.indexOf(value) === index,
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
for (let index = 0; index < frames.length; index += 1) {
|
|
48
|
-
tree.growth = frames[index];
|
|
49
|
-
renderForest(forest, index);
|
|
50
|
-
await delay(120);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
tree.growth = originalGrowth;
|
|
54
|
-
renderForest(forest);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
32
|
export async function viewer() {
|
|
58
33
|
const forestFile = getForestFile();
|
|
59
34
|
let forest = readForest();
|
|
@@ -63,6 +38,13 @@ export async function viewer() {
|
|
|
63
38
|
process.exit(1);
|
|
64
39
|
}
|
|
65
40
|
|
|
41
|
+
// Migrate old layouts on first view
|
|
42
|
+
if (forest && (!forest.layoutVersion || forest.layoutVersion < 2)) {
|
|
43
|
+
const termWidth = process.stdout.columns || 80;
|
|
44
|
+
migrateLayout(forest, termWidth);
|
|
45
|
+
// Will be written to disk by syncWidth below
|
|
46
|
+
}
|
|
47
|
+
|
|
66
48
|
// Save terminal width so plant knows how wide to spread trees
|
|
67
49
|
let ignoreNextChange = false;
|
|
68
50
|
function syncWidth() {
|
|
@@ -74,16 +56,65 @@ export async function viewer() {
|
|
|
74
56
|
}
|
|
75
57
|
}
|
|
76
58
|
|
|
59
|
+
let lastMaxId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
|
|
60
|
+
let lastTotalPrompts = forest.totalPrompts;
|
|
61
|
+
let animating = false;
|
|
62
|
+
|
|
63
|
+
let viewportX = forest.viewportX || 0;
|
|
64
|
+
const PAN_STEP = 4;
|
|
65
|
+
|
|
66
|
+
function getViewportWidth() {
|
|
67
|
+
return process.stdout.columns || 80;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clampViewport(x) {
|
|
71
|
+
const vw = getVirtualWidth(forest.trees.length, getViewportWidth());
|
|
72
|
+
return Math.max(0, Math.min(x, Math.max(0, vw - getViewportWidth())));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderForest(forest, twinkleSeed = 0) {
|
|
76
|
+
clearScreen();
|
|
77
|
+
const termWidth = process.stdout.columns || 80;
|
|
78
|
+
const vw = getVirtualWidth(forest.trees.length, termWidth);
|
|
79
|
+
process.stdout.write(renderFrame(forest, termWidth, {
|
|
80
|
+
twinkleSeed,
|
|
81
|
+
viewportX,
|
|
82
|
+
virtualWidth: vw,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function animateNewTree(forest, newTreeId) {
|
|
87
|
+
const tree = forest.trees.find((entry) => entry.id === newTreeId);
|
|
88
|
+
if (!tree) {
|
|
89
|
+
renderForest(forest);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const originalGrowth = tree.growth;
|
|
94
|
+
const frames = [0.12, 0.32, 0.6, originalGrowth].filter(
|
|
95
|
+
(value, index, values) => value <= originalGrowth && values.indexOf(value) === index,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
99
|
+
tree.growth = frames[index];
|
|
100
|
+
renderForest(forest, index);
|
|
101
|
+
await delay(120);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
tree.growth = originalGrowth;
|
|
105
|
+
renderForest(forest);
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
syncWidth();
|
|
78
109
|
hideCursor();
|
|
79
110
|
clearScreen();
|
|
80
111
|
renderForest(forest);
|
|
81
112
|
|
|
82
|
-
let lastMaxId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
|
|
83
|
-
let lastTotalPrompts = forest.totalPrompts;
|
|
84
|
-
let animating = false;
|
|
85
|
-
|
|
86
113
|
const cleanup = () => {
|
|
114
|
+
// Persist viewport position for next session
|
|
115
|
+
forest.viewportX = viewportX;
|
|
116
|
+
ignoreNextChange = true;
|
|
117
|
+
writeForest(forest);
|
|
87
118
|
showCursor();
|
|
88
119
|
clearScreen();
|
|
89
120
|
console.log(
|
|
@@ -100,6 +131,34 @@ export async function viewer() {
|
|
|
100
131
|
renderForest(forest);
|
|
101
132
|
});
|
|
102
133
|
|
|
134
|
+
// Enable raw mode for keypress handling
|
|
135
|
+
if (process.stdin.isTTY) {
|
|
136
|
+
process.stdin.setRawMode(true);
|
|
137
|
+
process.stdin.resume();
|
|
138
|
+
process.stdin.on("data", (data) => {
|
|
139
|
+
const key = data.toString();
|
|
140
|
+
// Ctrl+C or q to quit
|
|
141
|
+
if (key === "\x03" || key === "q") {
|
|
142
|
+
cleanup();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Left arrow: \x1b[D
|
|
146
|
+
if (key === "\x1b[D") {
|
|
147
|
+
viewportX = clampViewport(viewportX - PAN_STEP);
|
|
148
|
+
forest.viewportX = viewportX;
|
|
149
|
+
renderForest(forest);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Right arrow: \x1b[C
|
|
153
|
+
if (key === "\x1b[C") {
|
|
154
|
+
viewportX = clampViewport(viewportX + PAN_STEP);
|
|
155
|
+
forest.viewportX = viewportX;
|
|
156
|
+
renderForest(forest);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
103
162
|
// Check for changes — used by both fs.watch and polling fallback
|
|
104
163
|
async function checkForUpdates() {
|
|
105
164
|
if (animating) return;
|
|
@@ -121,6 +180,12 @@ export async function viewer() {
|
|
|
121
180
|
|
|
122
181
|
if (nextMaxId > lastMaxId) {
|
|
123
182
|
lastMaxId = nextMaxId;
|
|
183
|
+
// Center viewport on the new tree
|
|
184
|
+
const newTree = forest.trees.find((t) => t.id === nextMaxId);
|
|
185
|
+
if (newTree) {
|
|
186
|
+
const termWidth = getViewportWidth();
|
|
187
|
+
viewportX = clampViewport(newTree.x - Math.floor(termWidth / 2));
|
|
188
|
+
}
|
|
124
189
|
animating = true;
|
|
125
190
|
await animateNewTree(forest, nextMaxId);
|
|
126
191
|
animating = false;
|