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.
- package/LICENSE +21 -0
- package/README.md +43 -141
- package/bin/honeydew.js +4 -3
- package/package.json +8 -7
- package/src/animation-keyframes.js +890 -0
- package/src/animation.js +151 -0
- package/src/camera.js +54 -0
- package/src/diffactions.js +32 -0
- package/src/diffpanel.js +83 -0
- package/src/diffparser.js +44 -0
- package/src/diffwatch.js +52 -0
- package/src/migrate.js +47 -0
- package/src/plant.js +20 -4
- package/src/pointcloud.js +193 -0
- package/src/renderer.js +151 -18
- package/src/renderer3d.js +135 -0
- package/src/scanner.js +102 -0
- package/src/sprites.js +59 -1
- package/src/viewer.js +395 -103
|
@@ -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
|
+
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
|
|
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(
|
|
317
|
+
const biome = getBiome(treeCount);
|
|
229
318
|
const wilt = getWiltFactor(forest.lastActiveDate);
|
|
230
319
|
|
|
231
|
-
for (const star of generateStars(
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|