honeytree 1.1.5 → 1.2.0

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.
@@ -0,0 +1,193 @@
1
+ const SPECIES = {
2
+ oak: { name: "oak", colors: ["#55cc44", "#44bb33", "#66dd55", "#77ee66", "#3da832"], shape: "ellipsoid", widthScale: 1.4, heightScale: 1.0 },
3
+ pine: { name: "pine", colors: ["#33bbaa", "#22aa99", "#44ccbb", "#55ddcc", "#11997a"], shape: "cone", widthScale: 0.7, heightScale: 1.6 },
4
+ birch: { name: "birch", colors: ["#ff77cc", "#ee55bb", "#ff99dd", "#ffaaee", "#dd44aa"], shape: "ellipsoid", widthScale: 0.8, heightScale: 1.0 },
5
+ willow: { name: "willow", colors: ["#aadd44", "#99cc33", "#bbee55", "#ccff66", "#88bb22"], shape: "drooping", widthScale: 1.2, heightScale: 1.0 },
6
+ cherry: { name: "cherry", colors: ["#ff88cc", "#ff66bb", "#ffaadd", "#ffbbee", "#ee55aa", "#ff99ff", "#dd77cc"], shape: "sphere", widthScale: 1.0, heightScale: 1.0 },
7
+ default: { name: "default", colors: ["#88bb88", "#77aa77", "#99cc99", "#aaddaa"], shape: "ellipsoid", widthScale: 1.2, heightScale: 1.0 },
8
+ };
9
+
10
+ const TRUNK_COLOR = "#8B6914";
11
+ const AMBER_COLORS = ["#ff3333", "#ff5555", "#ff1111", "#ff4444", "#ffffff", "#ffcccc"];
12
+
13
+ const EXT_MAP = {
14
+ ".js": "oak", ".jsx": "oak", ".mjs": "oak", ".cjs": "oak",
15
+ ".ts": "pine", ".tsx": "pine", ".mts": "pine",
16
+ ".css": "birch", ".scss": "birch", ".sass": "birch", ".less": "birch",
17
+ ".py": "willow", ".pyw": "willow",
18
+ ".md": "cherry", ".json": "cherry", ".yaml": "cherry", ".yml": "cherry",
19
+ ".toml": "cherry", ".xml": "cherry", ".ini": "cherry",
20
+ };
21
+
22
+ export function getSpecies(extension) {
23
+ const key = EXT_MAP[extension] || "default";
24
+ return SPECIES[key];
25
+ }
26
+
27
+ function seededRandom(seed) {
28
+ let s = seed >>> 0;
29
+ return () => {
30
+ s = Math.imul((s >>> 16) ^ s, 0x45d9f3b) >>> 0;
31
+ s = Math.imul((s >>> 16) ^ s, 0x45d9f3b) >>> 0;
32
+ s = ((s >>> 16) ^ s) >>> 0;
33
+ return s / 0x100000000;
34
+ };
35
+ }
36
+
37
+ function hashString(str) {
38
+ let h = 0;
39
+ for (let i = 0; i < str.length; i++) {
40
+ h = Math.imul(31, h) + str.charCodeAt(i) | 0;
41
+ }
42
+ return h >>> 0;
43
+ }
44
+
45
+ export function generateTreeCloud(file, position, fileIndex = 0, lodScale = 1) {
46
+ const species = getSpecies(file.extension);
47
+ const colors = file.changed ? AMBER_COLORS : species.colors;
48
+ const seed = hashString(file.relativePath);
49
+ const rng = seededRandom(seed);
50
+
51
+ const sizeLog = Math.log2(Math.max(1, file.size));
52
+ const changedBoost = file.changed ? 1.5 : 1;
53
+ const basePoints = Math.round((220 + sizeLog * 35) * changedBoost);
54
+ const churnMultiplier = 1 + Math.min(1, (file.churn || 0) / 30);
55
+ const canopyCount = Math.round(basePoints * churnMultiplier * lodScale);
56
+
57
+ const height = 5 + sizeLog * 1.1;
58
+ const canopyCenterY = height;
59
+ const canopyRadiusX = (height * 0.45) * species.widthScale * changedBoost;
60
+ const canopyRadiusY = (height * 0.5) * species.heightScale;
61
+ const canopyRadiusZ = canopyRadiusX;
62
+
63
+ const points = [];
64
+
65
+ for (let i = 0; i < canopyCount; i++) {
66
+ let px, py, pz;
67
+
68
+ if (species.shape === "cone") {
69
+ const t = rng();
70
+ const angle = rng() * Math.PI * 2;
71
+ const radius = t * canopyRadiusX;
72
+ px = Math.cos(angle) * radius;
73
+ pz = Math.sin(angle) * radius;
74
+ py = canopyCenterY + canopyRadiusY * (1 - t);
75
+ } else if (species.shape === "drooping") {
76
+ const u = rng() * Math.PI * 2;
77
+ const v = rng() * Math.PI;
78
+ const r = rng();
79
+ px = Math.cos(u) * Math.sin(v) * canopyRadiusX * r;
80
+ py = canopyCenterY + Math.cos(v) * canopyRadiusY * r;
81
+ pz = Math.sin(u) * Math.sin(v) * canopyRadiusZ * r;
82
+ if (rng() < 0.3) {
83
+ py = canopyCenterY - rng() * canopyRadiusY * 0.8;
84
+ }
85
+ } else if (species.shape === "sphere") {
86
+ const u = rng() * Math.PI * 2;
87
+ const v = Math.acos(2 * rng() - 1);
88
+ const r = Math.cbrt(rng()) * canopyRadiusX;
89
+ px = Math.cos(u) * Math.sin(v) * r;
90
+ py = canopyCenterY + Math.cos(v) * r;
91
+ pz = Math.sin(u) * Math.sin(v) * r;
92
+ } else {
93
+ const u = rng() * Math.PI * 2;
94
+ const v = Math.acos(2 * rng() - 1);
95
+ const r = Math.cbrt(rng());
96
+ px = Math.cos(u) * Math.sin(v) * canopyRadiusX * r;
97
+ py = canopyCenterY + Math.cos(v) * canopyRadiusY * r;
98
+ pz = Math.sin(u) * Math.sin(v) * canopyRadiusZ * r;
99
+ }
100
+
101
+ const color = colors[Math.floor(rng() * colors.length)];
102
+ points.push({
103
+ x: position.x + px,
104
+ y: py,
105
+ z: position.z + pz,
106
+ color,
107
+ fileIndex,
108
+ });
109
+ }
110
+
111
+ const trunkCount = Math.round((8 + height * 2) * Math.max(0.5, lodScale));
112
+ for (let i = 0; i < trunkCount; i++) {
113
+ const t = i / trunkCount;
114
+ points.push({
115
+ x: position.x + (rng() - 0.5) * 0.5,
116
+ y: t * (canopyCenterY - canopyRadiusY * 0.5),
117
+ z: position.z + (rng() - 0.5) * 0.5,
118
+ color: TRUNK_COLOR,
119
+ fileIndex,
120
+ });
121
+ }
122
+
123
+ return points;
124
+ }
125
+
126
+ export function generateForestCloud(files) {
127
+ const dirGroups = {};
128
+ for (let i = 0; i < files.length; i++) {
129
+ const dir = files[i].directory || ".";
130
+ const topDir = dir === "." ? "." : dir.split("/")[0];
131
+ if (!dirGroups[topDir]) dirGroups[topDir] = [];
132
+ dirGroups[topDir].push({ file: files[i], index: i });
133
+ }
134
+
135
+ const dirs = Object.keys(dirGroups);
136
+ const totalFiles = files.length;
137
+ const spreadRadius = Math.max(20, Math.sqrt(totalFiles) * 8);
138
+
139
+ const MAX_POINTS = 80000;
140
+ const estimatedPointsPerFile = 400;
141
+ const estimatedTotal = totalFiles * estimatedPointsPerFile;
142
+ const lodScale = estimatedTotal > MAX_POINTS ? MAX_POINTS / estimatedTotal : 1;
143
+
144
+ const allPoints = [];
145
+ const filePaths = files.map((f) => f.relativePath);
146
+
147
+ dirs.forEach((dir, dirIndex) => {
148
+ const angle = (dirIndex / dirs.length) * Math.PI * 2;
149
+ const clusterCenterX = Math.cos(angle) * spreadRadius * 0.6;
150
+ const clusterCenterZ = Math.sin(angle) * spreadRadius * 0.6;
151
+
152
+ const group = dirGroups[dir];
153
+ const clusterSpread = Math.max(8, Math.sqrt(group.length) * 6);
154
+
155
+ group.forEach((entry, fileInGroup) => {
156
+ const seed = hashString(entry.file.relativePath);
157
+ const rng = seededRandom(seed);
158
+ const fx = clusterCenterX + (rng() - 0.5) * clusterSpread;
159
+ const fz = clusterCenterZ + (rng() - 0.5) * clusterSpread;
160
+
161
+ const treePoints = generateTreeCloud(entry.file, { x: fx, z: fz }, entry.index, lodScale);
162
+ allPoints.push(...treePoints);
163
+ });
164
+ });
165
+
166
+ return { points: allPoints, filePaths };
167
+ }
168
+
169
+ export function generateGroundPlane(radius) {
170
+ const points = [];
171
+ const step = 0.7;
172
+ const groundColors = ["#3a2a1a", "#4a3a2a", "#352515", "#2a1a0a"];
173
+
174
+ for (let x = -radius; x <= radius; x += step) {
175
+ for (let z = -radius; z <= radius; z += step) {
176
+ if (x * x + z * z > radius * radius) continue;
177
+
178
+ const seed = hashString(`ground_${x}_${z}`);
179
+ const rng = seededRandom(seed);
180
+ const color = groundColors[Math.floor(rng() * groundColors.length)];
181
+
182
+ points.push({
183
+ x,
184
+ y: 0,
185
+ z,
186
+ color,
187
+ fileIndex: -1,
188
+ });
189
+ }
190
+ }
191
+
192
+ return points;
193
+ }
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;
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,19 @@ 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
+
163
+ function getWindOffset(treeId, windTick) {
164
+ if (windTick == null) return 0;
165
+ const windDirection = Math.floor(windTick / 4) % 2 === 0 ? 1 : -1;
166
+ const treePhase = hash(treeId * 7) % 3;
167
+ const effectiveTick = Math.max(0, windTick - treePhase);
168
+ return (effectiveTick % 2 === 0) ? 0 : windDirection;
169
+ }
170
+
147
171
  function generateStars(width, biome, twinkle = 0) {
148
172
  const stars = [];
149
173
  for (let x = 0; x < width; x += 1) {
@@ -159,14 +183,16 @@ function generateStars(width, biome, twinkle = 0) {
159
183
  return stars;
160
184
  }
161
185
 
162
- function compositeSprite(buffer, sprite, centerX, baseY) {
186
+ function compositeSprite(buffer, sprite, centerX, baseY, canopyShiftX = 0) {
163
187
  const offsetX = centerX - Math.floor(sprite.width / 2);
188
+ const trunkRows = 2;
164
189
  for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex += 1) {
165
190
  const targetY = baseY - rowIndex;
166
191
  if (targetY < 0 || targetY >= buffer.length) continue;
167
192
  const row = sprite.rows[rowIndex];
193
+ const shiftX = rowIndex < trunkRows ? 0 : canopyShiftX;
168
194
  for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
169
- const targetX = offsetX + columnIndex;
195
+ const targetX = offsetX + columnIndex + shiftX;
170
196
  if (targetX < 0 || targetX >= buffer[0].length) continue;
171
197
  const [char, color] = row[columnIndex];
172
198
  if (!color) continue;
@@ -175,6 +201,41 @@ function compositeSprite(buffer, sprite, centerX, baseY) {
175
201
  }
176
202
  }
177
203
 
204
+ function renderGroundDetails(buffer, biome, virtualWidth, groundStart) {
205
+ if (biome.detailDensity === 0 || biome.detailTypes.length === 0) return;
206
+
207
+ const detailRow = groundStart - 1; // lowest tree row, just above ground
208
+
209
+ for (let x = 0; x < virtualWidth; x += 1) {
210
+ const h = hash(x * 53 + 9973);
211
+ if (h % biome.detailDensity !== 0) continue;
212
+
213
+ // Pick detail type deterministically
214
+ const detailType = biome.detailTypes[h % biome.detailTypes.length];
215
+ const sprite = getGroundDetail(detailType);
216
+
217
+ // Only place if all cells are currently empty (no tree pixel there)
218
+ // compositeSprite centers the sprite, so match that logic here
219
+ const offsetX = x - Math.floor(sprite.width / 2);
220
+ let blocked = false;
221
+ for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex++) {
222
+ const targetY = detailRow - rowIndex;
223
+ if (targetY < 0 || targetY >= buffer.length) { blocked = true; break; }
224
+ for (let colIndex = 0; colIndex < sprite.rows[rowIndex].length; colIndex++) {
225
+ const targetX = offsetX + colIndex;
226
+ if (targetX < 0 || targetX >= virtualWidth) { blocked = true; break; }
227
+ const [, color] = sprite.rows[rowIndex][colIndex];
228
+ if (color && buffer[targetY][targetX].color) { blocked = true; break; }
229
+ }
230
+ if (blocked) break;
231
+ }
232
+ if (blocked) continue;
233
+
234
+ // Place the detail sprite (compositeSprite centers at x)
235
+ compositeSprite(buffer, sprite, x, detailRow);
236
+ }
237
+ }
238
+
178
239
  function getNextMilestone(treeCount) {
179
240
  return MILESTONES.find((value) => treeCount < value) ?? treeCount + 100;
180
241
  }
@@ -198,7 +259,7 @@ function buildStreakSegment(forest) {
198
259
  return chalk.hex(STREAK_COLOR)(`${streak}-day streak`);
199
260
  }
200
261
 
201
- function buildStatsLine(forest, biome) {
262
+ function buildStatsLine(forest, biome, viewportX = 0, virtualWidth = 0, termWidth = 80) {
202
263
  const treeCount = forest.trees.length;
203
264
  const milestone = getNextMilestone(treeCount);
204
265
  const progress = milestone === 0 ? 0 : treeCount / milestone;
@@ -208,6 +269,25 @@ function buildStatsLine(forest, biome) {
208
269
  chalk.hex(BAR_FILL)("█".repeat(filledWidth)) +
209
270
  chalk.hex(BAR_EMPTY)("░".repeat(barWidth - filledWidth));
210
271
 
272
+ // Viewport minimap — only show when forest is wider than terminal
273
+ let minimap = "";
274
+ if (virtualWidth > termWidth) {
275
+ const mapWidth = 12;
276
+ const viewFraction = termWidth / virtualWidth;
277
+ const thumbWidth = Math.max(1, Math.round(viewFraction * mapWidth));
278
+ const maxOffset = virtualWidth - termWidth;
279
+ const thumbPos = maxOffset > 0
280
+ ? Math.round((viewportX / maxOffset) * (mapWidth - thumbWidth))
281
+ : 0;
282
+ const mapBar =
283
+ "─".repeat(thumbPos) +
284
+ "═".repeat(thumbWidth) +
285
+ "─".repeat(mapWidth - thumbPos - thumbWidth);
286
+ minimap = chalk.hex(STATS_TEXT)(" [") +
287
+ chalk.hex(BAR_FILL)(mapBar) +
288
+ chalk.hex(STATS_TEXT)("]");
289
+ }
290
+
211
291
  return (
212
292
  chalk.hex(STATS_ACCENT)(" honeytree") +
213
293
  chalk.hex(STATS_TEXT)(
@@ -217,23 +297,32 @@ function buildStatsLine(forest, biome) {
217
297
  chalk.hex(STATS_TEXT)(" · ") +
218
298
  bar +
219
299
  chalk.hex(STATS_TEXT)(` next: ${getNextTreeType(treeCount)}`) +
220
- chalk.hex("#555555")(` [${biome.label}]`)
300
+ chalk.hex("#555555")(` [${biome.label}]`) +
301
+ minimap
221
302
  );
222
303
  }
223
304
 
224
305
  export function renderFrame(forest, termWidth = 80, options = {}) {
225
306
  const width = Math.max(40, termWidth);
226
- const buffer = createBuffer(width);
307
+ const treeCount = forest.trees.length;
308
+ const virtualWidth = options.virtualWidth ?? getVirtualWidth(treeCount, width);
309
+ const viewportX = Math.max(
310
+ 0,
311
+ Math.min(options.viewportX ?? 0, Math.max(0, virtualWidth - width)),
312
+ );
313
+
314
+ // Build the full virtual-width buffer
315
+ const buffer = createBuffer(virtualWidth);
227
316
  const groundStart = SKY_ROWS + TREE_ROWS;
228
- const biome = getBiome(forest.trees.length);
317
+ const biome = getBiome(treeCount);
229
318
  const wilt = getWiltFactor(forest.lastActiveDate);
230
319
 
231
- for (const star of generateStars(width, biome, options.twinkleSeed ?? 0)) {
320
+ for (const star of generateStars(virtualWidth, biome, options.twinkleSeed ?? 0)) {
232
321
  buffer[star.y][star.x] = { char: star.char, color: star.color };
233
322
  }
234
323
 
235
324
  for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
236
- for (let x = 0; x < width; x += 1) {
325
+ for (let x = 0; x < virtualWidth; x += 1) {
237
326
  buffer[groundStart + rowIndex][x] = {
238
327
  char: "█",
239
328
  color: biome.ground[rowIndex],
@@ -241,21 +330,61 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
241
330
  }
242
331
  }
243
332
 
333
+ const windTick = options.windTick ?? null;
244
334
  const treeBaseY = groundStart - 1;
335
+ const spriteOverride = options.spriteOverride ?? null;
245
336
  for (const tree of forest.trees) {
246
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
337
+ const yOffset = getTreeYOffset(tree.id);
338
+ const sprite = (spriteOverride && spriteOverride.treeId === tree.id)
339
+ ? spriteOverride.sprite
340
+ : getSprite(tree.type, tree.growth);
341
+ const canopyShiftX = getWindOffset(tree.id, windTick);
342
+ compositeSprite(buffer, sprite, tree.x, treeBaseY - yOffset, canopyShiftX);
343
+ }
344
+
345
+ // Apply ground overlay (soil reaction during tree birth animation)
346
+ const groundOverlayOpt = options.groundOverlay ?? null;
347
+ if (groundOverlayOpt && groundOverlayOpt.overlays) {
348
+ const groundRow = groundStart; // first ground row
349
+ for (const overlay of groundOverlayOpt.overlays) {
350
+ const ox = groundOverlayOpt.treeX + overlay.dx;
351
+ if (ox >= 0 && ox < virtualWidth && groundRow < buffer.length) {
352
+ buffer[groundRow][ox] = { char: overlay.char, color: overlay.color };
353
+ }
354
+ }
355
+ }
356
+
357
+ // Ground pulse — brighten all ground pixels by 30% during planting flash
358
+ const groundPulse = options.groundPulse ?? false;
359
+ if (groundPulse) {
360
+ for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
361
+ for (let x = 0; x < virtualWidth; x += 1) {
362
+ const cell = buffer[groundStart + rowIndex][x];
363
+ if (cell.color) {
364
+ const c = parseHex(cell.color);
365
+ cell.color = toHex({
366
+ r: Math.min(255, c.r + (255 - c.r) * 0.3),
367
+ g: Math.min(255, c.g + (255 - c.g) * 0.3),
368
+ b: Math.min(255, c.b + (255 - c.b) * 0.3),
369
+ });
370
+ }
371
+ }
372
+ }
247
373
  }
248
374
 
249
- applyFog(buffer, wilt, width);
375
+ renderGroundDetails(buffer, biome, virtualWidth, groundStart);
250
376
 
377
+ applyFog(buffer, wilt, virtualWidth);
378
+
379
+ // Slice the viewport from the virtual buffer
251
380
  const lines = [];
252
381
  for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS - CTA_ROWS; y += 1) {
253
382
  let line = "";
254
- for (const cell of buffer[y]) {
383
+ for (let x = viewportX; x < viewportX + width; x += 1) {
384
+ const cell = buffer[y][x];
255
385
  if (!cell.color) {
256
386
  line += cell.char;
257
387
  } else {
258
- // Apply wilting to tree rows and ground (skip sky)
259
388
  const color = wilt > 0 && y >= SKY_ROWS ? wiltColor(cell.color, wilt) : cell.color;
260
389
  line += chalk.hex(color)(cell.char);
261
390
  }
@@ -264,9 +393,9 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
264
393
  }
265
394
 
266
395
  lines.push("");
267
- lines.push(buildStatsLine(forest, biome));
396
+ lines.push(buildStatsLine(forest, biome, viewportX, virtualWidth, width));
268
397
  lines.push(
269
- chalk.hex("#555555")(" add your forest to your README → ") +
398
+ chalk.hex("#555555")(" ← → pan · add your forest to your README → ") +
270
399
  chalk.hex(STATS_ACCENT)("honeytree badge"),
271
400
  );
272
401
 
@@ -297,9 +426,12 @@ export function buildScene(forest, width) {
297
426
 
298
427
  const treeBaseY = groundStart - 1;
299
428
  for (const tree of forest.trees) {
300
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
429
+ const yOffset = getTreeYOffset(tree.id);
430
+ compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
301
431
  }
302
432
 
433
+ renderGroundDetails(buffer, biome, w, groundStart);
434
+
303
435
  applyFog(buffer, wilt, w);
304
436
 
305
437
  if (wilt > 0) {
@@ -333,7 +465,8 @@ export function renderPlainText(forest, width = 60) {
333
465
 
334
466
  const treeBaseY = groundStart - 1;
335
467
  for (const tree of forest.trees) {
336
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY);
468
+ const yOffset = getTreeYOffset(tree.id);
469
+ compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
337
470
  }
338
471
 
339
472
  const lines = [];
@@ -0,0 +1,135 @@
1
+ import chalk from "chalk";
2
+
3
+ chalk.level = 3; // force 24-bit true color
4
+
5
+ const BLOCK_CHARS = ["█", "▓", "▒", "░"];
6
+ const BG_COLOR = "#0a0a1a";
7
+
8
+ function parseHex(hex) {
9
+ const h = hex.startsWith("#") ? hex.slice(1) : hex;
10
+ return {
11
+ r: parseInt(h.slice(0, 2), 16),
12
+ g: parseInt(h.slice(2, 4), 16),
13
+ b: parseInt(h.slice(4, 6), 16),
14
+ };
15
+ }
16
+
17
+ function toHex({ r, g, b }) {
18
+ const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
19
+ return `#${c(r)}${c(g)}${c(b)}`;
20
+ }
21
+
22
+ function lerpColor(hex, targetHex, factor) {
23
+ const c = parseHex(hex);
24
+ const t = parseHex(targetHex);
25
+ return toHex({
26
+ r: c.r + (t.r - c.r) * factor,
27
+ g: c.g + (t.g - c.g) * factor,
28
+ b: c.b + (t.b - c.b) * factor,
29
+ });
30
+ }
31
+
32
+ export function createFrameBuffer(width, height) {
33
+ const chars = Array.from({ length: height }, () =>
34
+ Array.from({ length: width }, () => ({ char: " ", color: null }))
35
+ );
36
+ const depth = Array.from({ length: height }, () =>
37
+ new Float64Array(width).fill(Infinity)
38
+ );
39
+ const fileIndices = Array.from({ length: height }, () =>
40
+ new Int32Array(width).fill(-1)
41
+ );
42
+
43
+ return { chars, depth, fileIndices, width, height };
44
+ }
45
+
46
+ export function rasterize(buf, projectedPoints, depthRange = null) {
47
+ let minDepth = Infinity;
48
+ let maxDepth = -Infinity;
49
+
50
+ if (depthRange) {
51
+ minDepth = depthRange.minDepth;
52
+ maxDepth = depthRange.maxDepth;
53
+ } else {
54
+ for (const p of projectedPoints) {
55
+ if (!p.visible) continue;
56
+ if (p.depth < minDepth) minDepth = p.depth;
57
+ if (p.depth > maxDepth) maxDepth = p.depth;
58
+ }
59
+ }
60
+
61
+ const depthSpan = maxDepth - minDepth || 1;
62
+
63
+ for (const p of projectedPoints) {
64
+ if (!p.visible) continue;
65
+
66
+ const sx = p.screenX;
67
+ const sy = p.screenY;
68
+
69
+ const t = (p.depth - minDepth) / depthSpan;
70
+ const charIndex = t === 0 ? 0 : Math.min(BLOCK_CHARS.length - 1, Math.ceil(t * BLOCK_CHARS.length) - 1);
71
+ const blockChar = BLOCK_CHARS[charIndex];
72
+ const dimFactor = t * 0.6;
73
+ const dimmedColor = lerpColor(p.color, BG_COLOR, dimFactor);
74
+
75
+ // Point splatting — closer points splat larger (2x2 for near, 1x1 for far)
76
+ const splatRadius = t < 0.3 ? 2 : t < 0.7 ? 1 : 0;
77
+
78
+ for (let dy = -splatRadius; dy <= splatRadius; dy++) {
79
+ for (let dx = -splatRadius; dx <= splatRadius; dx++) {
80
+ const px = sx + dx;
81
+ const py = sy + dy;
82
+
83
+ if (px < 0 || px >= buf.width || py < 0 || py >= buf.height) continue;
84
+
85
+ if (p.depth < buf.depth[py][px]) {
86
+ buf.depth[py][px] = p.depth;
87
+ buf.fileIndices[py][px] = p.fileIndex;
88
+
89
+ // Edge pixels of splat use lighter block char
90
+ const edgeChar = (dx === 0 && dy === 0) ? blockChar : BLOCK_CHARS[Math.min(BLOCK_CHARS.length - 1, charIndex + 1)];
91
+ buf.chars[py][px] = { char: edgeChar, color: dimmedColor };
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ export function renderBufferToString(buf, bgColor = BG_COLOR, changedIndices = null) {
99
+ const lines = [];
100
+
101
+ for (let y = 0; y < buf.height; y++) {
102
+ let line = "";
103
+ for (let x = 0; x < buf.width; x++) {
104
+ const cell = buf.chars[y][x];
105
+ if (!cell.color) {
106
+ line += chalk.hex(bgColor)(" ");
107
+ } else {
108
+ let color = cell.color;
109
+ if (changedIndices && buf.fileIndices[y][x] >= 0 && !changedIndices.has(buf.fileIndices[y][x])) {
110
+ color = lerpColor(color, bgColor, 0.6);
111
+ }
112
+ line += chalk.hex(color)(cell.char);
113
+ }
114
+ }
115
+ lines.push(line);
116
+ }
117
+
118
+ return lines.join("\n");
119
+ }
120
+
121
+ export function renderTopBar(hoveredFile, width, modified = false) {
122
+ if (!hoveredFile) return " ".repeat(width);
123
+ const tag = modified ? " [modified]" : "";
124
+ const plainLen = hoveredFile.length + tag.length + 2;
125
+ const pad = Math.max(0, Math.floor((width - plainLen) / 2));
126
+ const fileText = chalk.hex("#f5a50b")(` ${hoveredFile}`);
127
+ const tagText = modified ? chalk.hex("#ff8822")(" [modified]") : "";
128
+ return " ".repeat(pad) + fileText + tagText + " " + " ".repeat(Math.max(0, width - pad - plainLen));
129
+ }
130
+
131
+ export function renderStatusBar(fileCount, width, prefix = "") {
132
+ const rightPart = `${prefix}${fileCount} files | drag to rotate | +/- zoom | d diff | q quit r rescan `;
133
+ const padding = Math.max(0, width - rightPart.length);
134
+ return " ".repeat(padding) + chalk.hex("#8e8a84")(rightPart);
135
+ }