@kernel.chat/kbot 3.88.0 → 3.94.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/dist/tools/audio-engine.d.ts +72 -0
- package/dist/tools/audio-engine.js +426 -0
- package/dist/tools/coordination-engine.d.ts +127 -0
- package/dist/tools/coordination-engine.js +543 -0
- package/dist/tools/evolution-engine.d.ts +102 -0
- package/dist/tools/evolution-engine.js +746 -0
- package/dist/tools/foundation-engines.d.ts +111 -0
- package/dist/tools/foundation-engines.js +520 -0
- package/dist/tools/index.js +9 -0
- package/dist/tools/living-world.d.ts +161 -0
- package/dist/tools/living-world.js +1054 -0
- package/dist/tools/narrative-engine.d.ts +58 -0
- package/dist/tools/narrative-engine.js +681 -0
- package/dist/tools/render-engine.js +5 -5
- package/dist/tools/research-engine.d.ts +58 -0
- package/dist/tools/research-engine.js +550 -0
- package/dist/tools/rom-engine.d.ts +130 -0
- package/dist/tools/rom-engine.js +1208 -0
- package/dist/tools/social-engine.d.ts +100 -0
- package/dist/tools/social-engine.js +540 -0
- package/dist/tools/sprite-engine.js +40 -26
- package/dist/tools/stream-brain.js +4 -4
- package/dist/tools/stream-intelligence.d.ts +6 -0
- package/dist/tools/stream-intelligence.js +239 -49
- package/dist/tools/stream-renderer.js +805 -639
- package/dist/tools/stream-self-eval.d.ts +96 -0
- package/dist/tools/stream-self-eval.js +764 -0
- package/dist/tools/tile-world.d.ts +40 -0
- package/dist/tools/tile-world.js +1070 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
// kbot Tile World Engine — Minecraft-inspired 2D tile world for the stream
|
|
2
|
+
//
|
|
3
|
+
// Procedurally generated, explorable, buildable, persistent tile world.
|
|
4
|
+
// Replaces flat painted backgrounds with an actual tile grid.
|
|
5
|
+
// Renders 16x16px blocks with 3D-style shading, animated water/lava, ore flecks.
|
|
6
|
+
import { registerTool } from './index.js';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
// ─── Constants ────────────────────────────────────────────────
|
|
11
|
+
export const TILE_SIZE = 16; // pixels per tile
|
|
12
|
+
export const CHUNK_WIDTH = 36; // tiles visible horizontally (576px in the 580px robot panel)
|
|
13
|
+
export const CHUNK_HEIGHT = 26; // tiles visible vertically
|
|
14
|
+
export const WORLD_HEIGHT = 40; // total height including underground (26 visible + 14 below)
|
|
15
|
+
const BLOCK_COLORS = {
|
|
16
|
+
air: { top: 'transparent', face: 'transparent', dark: 'transparent' },
|
|
17
|
+
grass: { top: '#4ade80', face: '#65a30d', dark: '#3f6212' },
|
|
18
|
+
dirt: { top: '#92400e', face: '#78350f', dark: '#451a03' },
|
|
19
|
+
stone: { top: '#9ca3af', face: '#6b7280', dark: '#4b5563' },
|
|
20
|
+
sand: { top: '#fde68a', face: '#fbbf24', dark: '#d97706' },
|
|
21
|
+
water: { top: '#38bdf8', face: '#0284c7', dark: '#0369a1' },
|
|
22
|
+
wood: { top: '#a16207', face: '#854d0e', dark: '#713f12' },
|
|
23
|
+
leaves: { top: '#22c55e', face: '#16a34a', dark: '#15803d' },
|
|
24
|
+
ore_iron: { top: '#9ca3af', face: '#78716c', dark: '#57534e' },
|
|
25
|
+
ore_gold: { top: '#9ca3af', face: '#6b7280', dark: '#4b5563' },
|
|
26
|
+
ore_diamond: { top: '#9ca3af', face: '#6b7280', dark: '#4b5563' },
|
|
27
|
+
lava: { top: '#f97316', face: '#ea580c', dark: '#c2410c' },
|
|
28
|
+
snow: { top: '#f0f9ff', face: '#e0f2fe', dark: '#bae6fd' },
|
|
29
|
+
ice: { top: '#a5f3fc', face: '#67e8f9', dark: '#22d3ee' },
|
|
30
|
+
brick: { top: '#dc2626', face: '#b91c1c', dark: '#991b1b' },
|
|
31
|
+
glass: { top: '#dbeafe', face: '#bfdbfe', dark: '#93c5fd' },
|
|
32
|
+
};
|
|
33
|
+
// Ore fleck colors
|
|
34
|
+
const ORE_FLECK_COLORS = {
|
|
35
|
+
ore_iron: '#a0826d',
|
|
36
|
+
ore_gold: '#fbbf24',
|
|
37
|
+
ore_diamond: '#22d3ee',
|
|
38
|
+
};
|
|
39
|
+
// Valid placeable block types (water/lava only by natural generation)
|
|
40
|
+
const PLACEABLE_BLOCKS = new Set([
|
|
41
|
+
'grass', 'dirt', 'stone', 'sand', 'wood', 'leaves',
|
|
42
|
+
'snow', 'ice', 'brick', 'glass',
|
|
43
|
+
]);
|
|
44
|
+
// ─── Seeded PRNG ──────────────────────────────────────────────
|
|
45
|
+
/** Simple seeded pseudo-random number generator (mulberry32) */
|
|
46
|
+
function seededRandom(seed) {
|
|
47
|
+
let s = seed | 0;
|
|
48
|
+
return () => {
|
|
49
|
+
s = (s + 0x6D2B79F5) | 0;
|
|
50
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
51
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
52
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// ─── Terrain Generation ───────────────────────────────────────
|
|
56
|
+
// Water level: water fills enclosed basins up to this Y (absolute).
|
|
57
|
+
// Set during chunk generation relative to surfaceLevel.
|
|
58
|
+
const WATER_LEVEL_OFFSET = 2; // water surface sits 2 rows below surfaceLevel
|
|
59
|
+
/** Hash-based deterministic value for tile variation (returns 0-2) */
|
|
60
|
+
function tileHash(x, y, seed) {
|
|
61
|
+
let h = (x * 374761393 + y * 668265263 + seed * 1274126177) | 0;
|
|
62
|
+
h = (h ^ (h >> 13)) * 1103515245;
|
|
63
|
+
h = h ^ (h >> 16);
|
|
64
|
+
return ((h >>> 0) % 3);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 1D value noise: hash-based pseudo-random values at integer points,
|
|
68
|
+
* smoothly interpolated between them using cosine interpolation.
|
|
69
|
+
*/
|
|
70
|
+
function valueNoise1D(x, seed) {
|
|
71
|
+
const xi = Math.floor(x);
|
|
72
|
+
const frac = x - xi;
|
|
73
|
+
// Hash integer positions to pseudo-random values in [0, 1]
|
|
74
|
+
const hash = (n) => {
|
|
75
|
+
let h = ((n + seed * 7919) * 374761393) | 0;
|
|
76
|
+
h = (h ^ (h >> 13)) * 1103515245;
|
|
77
|
+
h = h ^ (h >> 16);
|
|
78
|
+
return ((h >>> 0) % 10000) / 10000;
|
|
79
|
+
};
|
|
80
|
+
const a = hash(xi);
|
|
81
|
+
const b = hash(xi + 1);
|
|
82
|
+
// Cosine interpolation for smooth curves
|
|
83
|
+
const t = (1 - Math.cos(frac * Math.PI)) * 0.5;
|
|
84
|
+
return a + (b - a) * t;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Multi-octave noise using layered value noise.
|
|
88
|
+
* 6 octaves, each half the amplitude and double the frequency.
|
|
89
|
+
* Returns a value in [0, 1].
|
|
90
|
+
*/
|
|
91
|
+
function multiOctaveNoise(x, seed, octaves = 6) {
|
|
92
|
+
let value = 0;
|
|
93
|
+
let amplitude = 1;
|
|
94
|
+
let frequency = 0.02; // base frequency — gentle hills
|
|
95
|
+
let maxValue = 0;
|
|
96
|
+
for (let i = 0; i < octaves; i++) {
|
|
97
|
+
value += amplitude * valueNoise1D(x * frequency, seed + i * 31337);
|
|
98
|
+
maxValue += amplitude;
|
|
99
|
+
amplitude *= 0.5;
|
|
100
|
+
frequency *= 2;
|
|
101
|
+
}
|
|
102
|
+
return value / maxValue; // normalized to [0, 1]
|
|
103
|
+
}
|
|
104
|
+
/** Compute terrain surface height for a given world X coordinate.
|
|
105
|
+
* Uses multi-octave noise with power redistribution for flatter lowlands
|
|
106
|
+
* and sharper peaks. Surface level sits at 40% of WORLD_HEIGHT.
|
|
107
|
+
*/
|
|
108
|
+
function terrainHeight(worldX, seed, _surfaceLevel) {
|
|
109
|
+
const baseLevel = Math.floor(WORLD_HEIGHT * 0.40); // surface at 40%
|
|
110
|
+
// Get noise in [0, 1], then center it to [-0.5, 0.5]
|
|
111
|
+
const raw = multiOctaveNoise(worldX, seed, 6) - 0.5;
|
|
112
|
+
// Power redistribution: preserve sign, flatten near zero, amplify extremes
|
|
113
|
+
const sign = raw >= 0 ? 1 : -1;
|
|
114
|
+
const shaped = sign * Math.pow(Math.abs(raw) * 2, 1.8) / 2;
|
|
115
|
+
// Scale to tile offset: gentle undulation of +/- 8 tiles from base
|
|
116
|
+
const maxSwing = 8;
|
|
117
|
+
const offset = Math.round(shaped * maxSwing * 2);
|
|
118
|
+
return Math.max(2, Math.min(WORLD_HEIGHT - 3, baseLevel + offset));
|
|
119
|
+
}
|
|
120
|
+
/** Check if a position should be a cave. Uses cellular-automata-style
|
|
121
|
+
* density from layered noise. Caves form coherent tunnels instead of
|
|
122
|
+
* random holes.
|
|
123
|
+
*/
|
|
124
|
+
function isCave(worldX, y, seed) {
|
|
125
|
+
// Two noise fields at different scales
|
|
126
|
+
const n1 = Math.sin(worldX * 0.06 + y * 0.09 + seed * 1.3) *
|
|
127
|
+
Math.cos(worldX * 0.04 + y * 0.07 + seed * 0.7);
|
|
128
|
+
const n2 = Math.sin(worldX * 0.12 + y * 0.05 + seed * 2.1) *
|
|
129
|
+
Math.cos(worldX * 0.09 + y * 0.13 + seed * 3.3);
|
|
130
|
+
// Combine: the product of two sine waves creates natural-looking voids
|
|
131
|
+
const density = (n1 + n2) / 2;
|
|
132
|
+
// Threshold: only open caves where both noise fields agree (narrow band)
|
|
133
|
+
return density > 0.28 && density < 0.5;
|
|
134
|
+
}
|
|
135
|
+
/** Generate a chunk at the given chunk X index */
|
|
136
|
+
export function generateChunk(world, chunkX) {
|
|
137
|
+
const tiles = [];
|
|
138
|
+
// Initialize all tiles to air
|
|
139
|
+
for (let y = 0; y < WORLD_HEIGHT; y++) {
|
|
140
|
+
tiles.push(new Array(CHUNK_WIDTH).fill('air'));
|
|
141
|
+
}
|
|
142
|
+
const rng = seededRandom(world.seed * 7919 + chunkX * 104729);
|
|
143
|
+
// Water level: absolute Y coordinate for water surface
|
|
144
|
+
const waterLevel = Math.floor(WORLD_HEIGHT * 0.40) + WATER_LEVEL_OFFSET;
|
|
145
|
+
// Pre-compute surface heights for all columns (needed for basin detection + slope)
|
|
146
|
+
const surfaces = [];
|
|
147
|
+
for (let lx = 0; lx < CHUNK_WIDTH; lx++) {
|
|
148
|
+
const worldX = chunkX * CHUNK_WIDTH + lx;
|
|
149
|
+
const surface = terrainHeight(worldX, world.seed, world.surfaceLevel);
|
|
150
|
+
surfaces[lx] = Math.max(2, Math.min(WORLD_HEIGHT - 3, surface));
|
|
151
|
+
}
|
|
152
|
+
// Compute neighbor surfaces outside chunk bounds for edge-basin detection.
|
|
153
|
+
// Look up to 8 columns beyond each edge to find the nearest bank.
|
|
154
|
+
const BANK_SEARCH = 8;
|
|
155
|
+
let leftNeighborSurface = WORLD_HEIGHT; // default: no bank found (deep)
|
|
156
|
+
for (let d = 1; d <= BANK_SEARCH; d++) {
|
|
157
|
+
const s = Math.max(2, Math.min(WORLD_HEIGHT - 3, terrainHeight(chunkX * CHUNK_WIDTH - d, world.seed, world.surfaceLevel)));
|
|
158
|
+
if (s <= waterLevel) {
|
|
159
|
+
leftNeighborSurface = s;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
let rightNeighborSurface = WORLD_HEIGHT;
|
|
164
|
+
for (let d = 0; d < BANK_SEARCH; d++) {
|
|
165
|
+
const s = Math.max(2, Math.min(WORLD_HEIGHT - 3, terrainHeight((chunkX + 1) * CHUNK_WIDTH + d, world.seed, world.surfaceLevel)));
|
|
166
|
+
if (s <= waterLevel) {
|
|
167
|
+
rightNeighborSurface = s;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// ── Pass 1: Fill terrain (surface + underground) ──
|
|
172
|
+
for (let lx = 0; lx < CHUNK_WIDTH; lx++) {
|
|
173
|
+
const worldX = chunkX * CHUNK_WIDTH + lx;
|
|
174
|
+
const clampedSurface = surfaces[lx];
|
|
175
|
+
// Biome tinting: use a slow noise to pick between grass/sand/snow at surface
|
|
176
|
+
const biomeNoise = multiOctaveNoise(worldX, world.seed + 50000, 3);
|
|
177
|
+
// Default to grass; sand near water level, snow at high elevations
|
|
178
|
+
let surfaceBlock = 'grass';
|
|
179
|
+
if (clampedSurface >= waterLevel - 1 && clampedSurface <= waterLevel + 1) {
|
|
180
|
+
surfaceBlock = 'sand'; // beaches near water level
|
|
181
|
+
}
|
|
182
|
+
else if (clampedSurface <= Math.floor(WORLD_HEIGHT * 0.15)) {
|
|
183
|
+
surfaceBlock = biomeNoise > 0.5 ? 'snow' : 'grass';
|
|
184
|
+
}
|
|
185
|
+
// Fill from surface down
|
|
186
|
+
for (let y = clampedSurface; y < WORLD_HEIGHT; y++) {
|
|
187
|
+
const depth = y - clampedSurface;
|
|
188
|
+
if (depth === 0) {
|
|
189
|
+
tiles[y][lx] = surfaceBlock;
|
|
190
|
+
}
|
|
191
|
+
else if (depth <= 3 + Math.floor(rng() * 2)) {
|
|
192
|
+
// Dirt layer (3-4 blocks); sand under sand surfaces
|
|
193
|
+
tiles[y][lx] = surfaceBlock === 'sand' ? 'sand' : 'dirt';
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// Stone layer — check for caves
|
|
197
|
+
if (isCave(worldX, y, world.seed)) {
|
|
198
|
+
if (y >= WORLD_HEIGHT - 4 && rng() < 0.3) {
|
|
199
|
+
tiles[y][lx] = 'lava';
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
tiles[y][lx] = 'air';
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Place stone with possible ores
|
|
207
|
+
const oreRoll = rng();
|
|
208
|
+
if (oreRoll < 0.005 && depth > 15) {
|
|
209
|
+
tiles[y][lx] = 'ore_diamond';
|
|
210
|
+
}
|
|
211
|
+
else if (oreRoll < 0.025 && depth > 8) {
|
|
212
|
+
tiles[y][lx] = 'ore_gold';
|
|
213
|
+
}
|
|
214
|
+
else if (oreRoll < 0.075) {
|
|
215
|
+
tiles[y][lx] = 'ore_iron';
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
tiles[y][lx] = 'stone';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// ── Pass 2: Fill ENCLOSED basins with water (not every air gap) ──
|
|
225
|
+
// A basin is a contiguous run of columns where the surface dips below
|
|
226
|
+
// waterLevel AND is enclosed on both sides by terrain at or above waterLevel.
|
|
227
|
+
// We scan left-to-right, finding basins, then fill only those.
|
|
228
|
+
{
|
|
229
|
+
let lx = 0;
|
|
230
|
+
let waterColumns = 0;
|
|
231
|
+
const maxWaterColumns = Math.floor(CHUNK_WIDTH * 0.30); // max 30% water
|
|
232
|
+
while (lx < CHUNK_WIDTH && waterColumns < maxWaterColumns) {
|
|
233
|
+
// Skip columns at or above water level
|
|
234
|
+
if (surfaces[lx] <= waterLevel) {
|
|
235
|
+
lx++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// Found a column below water level — scan for basin extent
|
|
239
|
+
const basinStart = lx;
|
|
240
|
+
while (lx < CHUNK_WIDTH && surfaces[lx] > waterLevel) {
|
|
241
|
+
lx++;
|
|
242
|
+
}
|
|
243
|
+
const basinEnd = lx; // exclusive
|
|
244
|
+
// Check enclosure: left bank and right bank must be at or above waterLevel
|
|
245
|
+
const leftBank = basinStart > 0 ? surfaces[basinStart - 1] : leftNeighborSurface;
|
|
246
|
+
const rightBank = basinEnd < CHUNK_WIDTH ? surfaces[basinEnd] : rightNeighborSurface;
|
|
247
|
+
const leftEnclosed = leftBank <= waterLevel;
|
|
248
|
+
const rightEnclosed = rightBank <= waterLevel;
|
|
249
|
+
if (leftEnclosed && rightEnclosed) {
|
|
250
|
+
// Enclosed basin — fill air between waterLevel and surface with water.
|
|
251
|
+
// In our coordinate system, higher Y = deeper. The surface Y is
|
|
252
|
+
// greater than waterLevel for basin columns. Fill from waterLevel
|
|
253
|
+
// downward to the surface.
|
|
254
|
+
for (let bx = basinStart; bx < basinEnd && waterColumns < maxWaterColumns; bx++) {
|
|
255
|
+
for (let y = waterLevel; y < surfaces[bx]; y++) {
|
|
256
|
+
if (tiles[y][bx] === 'air') {
|
|
257
|
+
tiles[y][bx] = 'water';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Convert the surface block and adjacent blocks to sand (beach effect)
|
|
261
|
+
if (tiles[surfaces[bx]][bx] === 'grass' || tiles[surfaces[bx]][bx] === 'dirt') {
|
|
262
|
+
tiles[surfaces[bx]][bx] = 'sand';
|
|
263
|
+
}
|
|
264
|
+
waterColumns++;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ── Pass 3: Trees — only on flat areas (slope < 2 between adjacent columns) ──
|
|
270
|
+
// Track last tree position to avoid clustering
|
|
271
|
+
let lastTreeX = -5;
|
|
272
|
+
for (let lx = 2; lx < CHUNK_WIDTH - 2; lx++) {
|
|
273
|
+
if (lx - lastTreeX < 4)
|
|
274
|
+
continue; // minimum spacing between trees
|
|
275
|
+
const clampedSurface = surfaces[lx];
|
|
276
|
+
// Slope check: compare with left and right neighbor heights
|
|
277
|
+
const slopeLeft = Math.abs(surfaces[lx] - surfaces[lx - 1]);
|
|
278
|
+
const slopeRight = Math.abs(surfaces[lx] - surfaces[lx + 1]);
|
|
279
|
+
if (slopeLeft >= 2 || slopeRight >= 2)
|
|
280
|
+
continue; // too steep
|
|
281
|
+
// Must be on grass (not sand, water, snow)
|
|
282
|
+
if (tiles[clampedSurface]?.[lx] !== 'grass')
|
|
283
|
+
continue;
|
|
284
|
+
// 12% base chance
|
|
285
|
+
if (rng() > 0.12)
|
|
286
|
+
continue;
|
|
287
|
+
if (clampedSurface < 6)
|
|
288
|
+
continue; // need headroom
|
|
289
|
+
lastTreeX = lx;
|
|
290
|
+
const trunkHeight = 3 + Math.floor(rng() * 3); // 3-5 blocks tall
|
|
291
|
+
// Place trunk
|
|
292
|
+
for (let th = 1; th <= trunkHeight; th++) {
|
|
293
|
+
const ty = clampedSurface - th;
|
|
294
|
+
if (ty >= 0)
|
|
295
|
+
tiles[ty][lx] = 'wood';
|
|
296
|
+
}
|
|
297
|
+
// Place leaves crown (3x3 centered on trunk top, plus 1 block above)
|
|
298
|
+
const crownY = clampedSurface - trunkHeight;
|
|
299
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
300
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
301
|
+
const ly = crownY + dy;
|
|
302
|
+
const llx = lx + dx;
|
|
303
|
+
if (ly >= 0 && ly < WORLD_HEIGHT && llx >= 0 && llx < CHUNK_WIDTH) {
|
|
304
|
+
if (tiles[ly][llx] === 'air')
|
|
305
|
+
tiles[ly][llx] = 'leaves';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Extra leaf on top
|
|
310
|
+
if (crownY - 1 >= 0 && tiles[crownY - 1][lx] === 'air') {
|
|
311
|
+
tiles[crownY - 1][lx] = 'leaves';
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
x: chunkX,
|
|
316
|
+
tiles,
|
|
317
|
+
generated: true,
|
|
318
|
+
modified: false,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// ─── World Init / Load / Save ─────────────────────────────────
|
|
322
|
+
const KBOT_DIR = join(homedir(), '.kbot');
|
|
323
|
+
const WORLD_FILE = join(KBOT_DIR, 'stream-world.json');
|
|
324
|
+
/** Initialize a fresh tile world */
|
|
325
|
+
export function initTileWorld(seed) {
|
|
326
|
+
const world = {
|
|
327
|
+
chunks: new Map(),
|
|
328
|
+
cameraX: 0,
|
|
329
|
+
surfaceLevel: Math.floor(WORLD_HEIGHT * 0.40), // 40% of world height
|
|
330
|
+
seed: seed ?? Math.floor(Math.random() * 999999),
|
|
331
|
+
timeOfDay: 'day',
|
|
332
|
+
weather: 'clear',
|
|
333
|
+
};
|
|
334
|
+
// Pre-generate the starting chunk (chunk 0)
|
|
335
|
+
const startChunk = generateChunk(world, 0);
|
|
336
|
+
world.chunks.set(0, startChunk);
|
|
337
|
+
// Also generate chunk -1 so the camera can look left
|
|
338
|
+
const leftChunk = generateChunk(world, -1);
|
|
339
|
+
world.chunks.set(-1, leftChunk);
|
|
340
|
+
return world;
|
|
341
|
+
}
|
|
342
|
+
/** Save world to disk (only modified chunks to keep file small) */
|
|
343
|
+
export function saveWorld(world) {
|
|
344
|
+
try {
|
|
345
|
+
if (!existsSync(KBOT_DIR)) {
|
|
346
|
+
mkdirSync(KBOT_DIR, { recursive: true });
|
|
347
|
+
}
|
|
348
|
+
const data = {
|
|
349
|
+
seed: world.seed,
|
|
350
|
+
cameraX: world.cameraX,
|
|
351
|
+
surfaceLevel: world.surfaceLevel,
|
|
352
|
+
timeOfDay: world.timeOfDay,
|
|
353
|
+
weather: world.weather,
|
|
354
|
+
chunks: Array.from(world.chunks.entries())
|
|
355
|
+
.filter(([_, c]) => c.modified)
|
|
356
|
+
.map(([x, c]) => ({ x, tiles: c.tiles })),
|
|
357
|
+
};
|
|
358
|
+
writeFileSync(WORLD_FILE, JSON.stringify(data));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Silently fail — don't crash the stream
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Load world from disk, returns null if no save exists */
|
|
365
|
+
export function loadWorld() {
|
|
366
|
+
try {
|
|
367
|
+
if (!existsSync(WORLD_FILE))
|
|
368
|
+
return null;
|
|
369
|
+
const raw = JSON.parse(readFileSync(WORLD_FILE, 'utf-8'));
|
|
370
|
+
const world = {
|
|
371
|
+
chunks: new Map(),
|
|
372
|
+
cameraX: raw.cameraX ?? 0,
|
|
373
|
+
surfaceLevel: raw.surfaceLevel ?? 12,
|
|
374
|
+
seed: raw.seed ?? 42,
|
|
375
|
+
timeOfDay: raw.timeOfDay ?? 'day',
|
|
376
|
+
weather: raw.weather ?? 'clear',
|
|
377
|
+
};
|
|
378
|
+
// Restore modified chunks
|
|
379
|
+
if (Array.isArray(raw.chunks)) {
|
|
380
|
+
for (const saved of raw.chunks) {
|
|
381
|
+
world.chunks.set(saved.x, {
|
|
382
|
+
x: saved.x,
|
|
383
|
+
tiles: saved.tiles,
|
|
384
|
+
generated: true,
|
|
385
|
+
modified: true,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return world;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ─── Camera / Scrolling ───────────────────────────────────────
|
|
396
|
+
/** Update camera to follow the robot with smooth lerp */
|
|
397
|
+
export function updateCamera(world, robotWorldX, panelWidth = 576) {
|
|
398
|
+
const targetX = robotWorldX - panelWidth / 2;
|
|
399
|
+
world.cameraX += (targetX - world.cameraX) * 0.1;
|
|
400
|
+
}
|
|
401
|
+
// ─── Coordinate Conversion ────────────────────────────────────
|
|
402
|
+
/** Convert world pixel X to tile X (absolute) */
|
|
403
|
+
export function worldXToTile(worldPixelX) {
|
|
404
|
+
return Math.floor(worldPixelX / TILE_SIZE);
|
|
405
|
+
}
|
|
406
|
+
/** Convert tile X to world pixel X */
|
|
407
|
+
export function tileToWorldX(tileX) {
|
|
408
|
+
return tileX * TILE_SIZE;
|
|
409
|
+
}
|
|
410
|
+
/** Get which chunk a tile X belongs to */
|
|
411
|
+
function tileXToChunkIndex(tileX) {
|
|
412
|
+
return Math.floor(tileX / CHUNK_WIDTH);
|
|
413
|
+
}
|
|
414
|
+
/** Get local X within a chunk */
|
|
415
|
+
function tileXToLocalX(tileX) {
|
|
416
|
+
return ((tileX % CHUNK_WIDTH) + CHUNK_WIDTH) % CHUNK_WIDTH;
|
|
417
|
+
}
|
|
418
|
+
// ─── Tile Access Helpers ──────────────────────────────────────
|
|
419
|
+
/** Get a tile at absolute tile coordinates, generating chunk if needed */
|
|
420
|
+
function getTile(world, tileX, tileY) {
|
|
421
|
+
if (tileY < 0 || tileY >= WORLD_HEIGHT)
|
|
422
|
+
return 'air';
|
|
423
|
+
const chunkIdx = tileXToChunkIndex(tileX);
|
|
424
|
+
let chunk = world.chunks.get(chunkIdx);
|
|
425
|
+
if (!chunk) {
|
|
426
|
+
chunk = generateChunk(world, chunkIdx);
|
|
427
|
+
world.chunks.set(chunkIdx, chunk);
|
|
428
|
+
}
|
|
429
|
+
const lx = tileXToLocalX(tileX);
|
|
430
|
+
return chunk.tiles[tileY][lx];
|
|
431
|
+
}
|
|
432
|
+
/** Set a tile at absolute tile coordinates */
|
|
433
|
+
function setTile(world, tileX, tileY, block) {
|
|
434
|
+
if (tileY < 0 || tileY >= WORLD_HEIGHT)
|
|
435
|
+
return false;
|
|
436
|
+
const chunkIdx = tileXToChunkIndex(tileX);
|
|
437
|
+
let chunk = world.chunks.get(chunkIdx);
|
|
438
|
+
if (!chunk) {
|
|
439
|
+
chunk = generateChunk(world, chunkIdx);
|
|
440
|
+
world.chunks.set(chunkIdx, chunk);
|
|
441
|
+
}
|
|
442
|
+
const lx = tileXToLocalX(tileX);
|
|
443
|
+
chunk.tiles[tileY][lx] = block;
|
|
444
|
+
chunk.modified = true;
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
// ─── Hex color utilities ──────────────────────────────────────
|
|
448
|
+
function hexToRgb(hex) {
|
|
449
|
+
const h = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
450
|
+
return [
|
|
451
|
+
parseInt(h.slice(0, 2), 16),
|
|
452
|
+
parseInt(h.slice(2, 4), 16),
|
|
453
|
+
parseInt(h.slice(4, 6), 16),
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
function adjustBrightness(hex, factor) {
|
|
457
|
+
const [r, g, b] = hexToRgb(hex);
|
|
458
|
+
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v * factor)));
|
|
459
|
+
return `rgb(${clamp(r)},${clamp(g)},${clamp(b)})`;
|
|
460
|
+
}
|
|
461
|
+
// ─── Autotile Types ──────────────────────────────────────────
|
|
462
|
+
/** Block types that receive autotiling (soft edges based on neighbors) */
|
|
463
|
+
const AUTOTILE_BLOCKS = new Set(['grass', 'sand', 'snow', 'water']);
|
|
464
|
+
// ─── Rendering ────────────────────────────────────────────────
|
|
465
|
+
/**
|
|
466
|
+
* Compute 4-bit autotile mask for a block.
|
|
467
|
+
* Bits: North=1, West=2, East=4, South=8.
|
|
468
|
+
* A bit is set if the neighbor in that direction is the SAME block type.
|
|
469
|
+
*/
|
|
470
|
+
function autotileMask(world, tileX, tileY, blockType) {
|
|
471
|
+
let mask = 0;
|
|
472
|
+
if (getTile(world, tileX, tileY - 1) === blockType)
|
|
473
|
+
mask |= 1; // North
|
|
474
|
+
if (getTile(world, tileX - 1, tileY) === blockType)
|
|
475
|
+
mask |= 2; // West
|
|
476
|
+
if (getTile(world, tileX + 1, tileY) === blockType)
|
|
477
|
+
mask |= 4; // East
|
|
478
|
+
if (getTile(world, tileX, tileY + 1) === blockType)
|
|
479
|
+
mask |= 8; // South
|
|
480
|
+
return mask;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Draw tile variation details. Uses tileHash to pick 1 of 3 visual variants.
|
|
484
|
+
* Each variant adds minor pixel-level details (cracks, grass blades, grain).
|
|
485
|
+
*/
|
|
486
|
+
function drawTileVariation(ctx, screenX, screenY, block, seed, tileWorldX, tileWorldY) {
|
|
487
|
+
const S = TILE_SIZE;
|
|
488
|
+
const variant = tileHash(tileWorldX, tileWorldY, seed);
|
|
489
|
+
const colors = BLOCK_COLORS[block];
|
|
490
|
+
switch (block) {
|
|
491
|
+
case 'grass': {
|
|
492
|
+
// Variant grass blade positions (tiny 1px darker marks)
|
|
493
|
+
ctx.fillStyle = colors.dark;
|
|
494
|
+
if (variant === 0) {
|
|
495
|
+
ctx.fillRect(screenX + 3, screenY + 2, 1, 2);
|
|
496
|
+
ctx.fillRect(screenX + 10, screenY + 1, 1, 3);
|
|
497
|
+
}
|
|
498
|
+
else if (variant === 1) {
|
|
499
|
+
ctx.fillRect(screenX + 6, screenY + 2, 1, 2);
|
|
500
|
+
ctx.fillRect(screenX + 13, screenY + 3, 1, 2);
|
|
501
|
+
ctx.fillRect(screenX + 1, screenY + 1, 1, 2);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
ctx.fillRect(screenX + 4, screenY + 3, 1, 2);
|
|
505
|
+
ctx.fillRect(screenX + 11, screenY + 2, 1, 2);
|
|
506
|
+
}
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
case 'dirt': {
|
|
510
|
+
// Small rock/crack details
|
|
511
|
+
ctx.fillStyle = adjustBrightness(colors.face, variant === 0 ? 0.8 : variant === 1 ? 1.15 : 0.9);
|
|
512
|
+
if (variant === 0) {
|
|
513
|
+
ctx.fillRect(screenX + 4, screenY + 5, 2, 1);
|
|
514
|
+
ctx.fillRect(screenX + 10, screenY + 10, 2, 1);
|
|
515
|
+
}
|
|
516
|
+
else if (variant === 1) {
|
|
517
|
+
ctx.fillRect(screenX + 7, screenY + 3, 1, 2);
|
|
518
|
+
ctx.fillRect(screenX + 2, screenY + 9, 2, 2);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
ctx.fillRect(screenX + 5, screenY + 7, 3, 1);
|
|
522
|
+
ctx.fillRect(screenX + 12, screenY + 4, 1, 1);
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
case 'stone': {
|
|
527
|
+
// Crack lines and shade variation
|
|
528
|
+
const shade = variant === 0 ? 0.85 : variant === 1 ? 0.92 : 1.08;
|
|
529
|
+
ctx.fillStyle = adjustBrightness(colors.face, shade);
|
|
530
|
+
if (variant === 0) {
|
|
531
|
+
ctx.fillRect(screenX + 2, screenY + 6, 4, 1);
|
|
532
|
+
ctx.fillRect(screenX + 5, screenY + 6, 1, 3);
|
|
533
|
+
}
|
|
534
|
+
else if (variant === 1) {
|
|
535
|
+
ctx.fillRect(screenX + 8, screenY + 4, 1, 5);
|
|
536
|
+
ctx.fillRect(screenX + 3, screenY + 11, 3, 1);
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
ctx.fillRect(screenX + 6, screenY + 2, 3, 1);
|
|
540
|
+
ctx.fillRect(screenX + 10, screenY + 8, 2, 2);
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
case 'sand': {
|
|
545
|
+
// Subtle grain dots
|
|
546
|
+
ctx.fillStyle = adjustBrightness(colors.face, variant === 0 ? 1.1 : 0.9);
|
|
547
|
+
const offsets = variant === 0
|
|
548
|
+
? [[3, 4], [9, 7], [13, 11]]
|
|
549
|
+
: variant === 1
|
|
550
|
+
? [[5, 3], [7, 10], [12, 5]]
|
|
551
|
+
: [[2, 8], [8, 3], [11, 12]];
|
|
552
|
+
for (const [ox, oy] of offsets) {
|
|
553
|
+
ctx.fillRect(screenX + ox, screenY + oy, 1, 1);
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case 'snow': {
|
|
558
|
+
// Sparkle dots
|
|
559
|
+
ctx.fillStyle = '#ffffff';
|
|
560
|
+
ctx.globalAlpha = 0.4;
|
|
561
|
+
const sparkles = variant === 0
|
|
562
|
+
? [[4, 3], [11, 8]]
|
|
563
|
+
: variant === 1
|
|
564
|
+
? [[7, 5], [2, 11], [13, 3]]
|
|
565
|
+
: [[5, 9], [10, 4]];
|
|
566
|
+
for (const [ox, oy] of sparkles) {
|
|
567
|
+
ctx.fillRect(screenX + ox, screenY + oy, 1, 1);
|
|
568
|
+
}
|
|
569
|
+
ctx.globalAlpha = 1;
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
default:
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Draw autotile softened edges. For each edge where the neighbor is NOT
|
|
578
|
+
* the same type, we draw a rounded/softened transition (2px inset with the
|
|
579
|
+
* face color of whatever is adjacent — typically air → sky gradient fakes).
|
|
580
|
+
* This makes grass-to-air edges look curved rather than blocky.
|
|
581
|
+
*/
|
|
582
|
+
function drawAutotileEdges(ctx, screenX, screenY, mask, colors) {
|
|
583
|
+
const S = TILE_SIZE;
|
|
584
|
+
const INSET = 2; // how many pixels to soften
|
|
585
|
+
// For missing neighbors, draw a subtle rounded corner effect
|
|
586
|
+
// by drawing the dark shade in the corner pixels
|
|
587
|
+
ctx.fillStyle = colors.dark;
|
|
588
|
+
// If no north neighbor → soften top-left and top-right corners
|
|
589
|
+
if (!(mask & 1)) {
|
|
590
|
+
ctx.fillRect(screenX, screenY, INSET, 1);
|
|
591
|
+
ctx.fillRect(screenX + S - INSET, screenY, INSET, 1);
|
|
592
|
+
ctx.fillRect(screenX, screenY + 1, 1, 1);
|
|
593
|
+
ctx.fillRect(screenX + S - 1, screenY + 1, 1, 1);
|
|
594
|
+
}
|
|
595
|
+
// If no south neighbor → soften bottom corners
|
|
596
|
+
if (!(mask & 8)) {
|
|
597
|
+
ctx.fillRect(screenX, screenY + S - 1, INSET, 1);
|
|
598
|
+
ctx.fillRect(screenX + S - INSET, screenY + S - 1, INSET, 1);
|
|
599
|
+
ctx.fillRect(screenX, screenY + S - 2, 1, 1);
|
|
600
|
+
ctx.fillRect(screenX + S - 1, screenY + S - 2, 1, 1);
|
|
601
|
+
}
|
|
602
|
+
// If no west neighbor → soften left edge
|
|
603
|
+
if (!(mask & 2)) {
|
|
604
|
+
ctx.fillRect(screenX, screenY, 1, INSET);
|
|
605
|
+
ctx.fillRect(screenX, screenY + S - INSET, 1, INSET);
|
|
606
|
+
ctx.fillRect(screenX + 1, screenY, 1, 1);
|
|
607
|
+
ctx.fillRect(screenX + 1, screenY + S - 1, 1, 1);
|
|
608
|
+
}
|
|
609
|
+
// If no east neighbor → soften right edge
|
|
610
|
+
if (!(mask & 4)) {
|
|
611
|
+
ctx.fillRect(screenX + S - 1, screenY, 1, INSET);
|
|
612
|
+
ctx.fillRect(screenX + S - 1, screenY + S - INSET, 1, INSET);
|
|
613
|
+
ctx.fillRect(screenX + S - 2, screenY, 1, 1);
|
|
614
|
+
ctx.fillRect(screenX + S - 2, screenY + S - 1, 1, 1);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Draw a single tile block with 3D-style shading, autotiling, and variation */
|
|
618
|
+
function drawBlock(ctx, screenX, screenY, block, frame, tileWorldX, tileWorldY, world) {
|
|
619
|
+
if (block === 'air')
|
|
620
|
+
return;
|
|
621
|
+
const colors = BLOCK_COLORS[block];
|
|
622
|
+
const S = TILE_SIZE;
|
|
623
|
+
// Special: water at 60% opacity with autotile
|
|
624
|
+
if (block === 'water') {
|
|
625
|
+
ctx.save();
|
|
626
|
+
ctx.globalAlpha = 0.6;
|
|
627
|
+
const waveOffset = Math.sin(frame * 0.15 + tileWorldX * 0.5) * 2;
|
|
628
|
+
// Main body
|
|
629
|
+
ctx.fillStyle = colors.face;
|
|
630
|
+
ctx.fillRect(screenX, screenY + 1, S, S - 1);
|
|
631
|
+
// Top row with wave
|
|
632
|
+
ctx.fillStyle = colors.top;
|
|
633
|
+
ctx.fillRect(screenX + Math.round(waveOffset), screenY, S, 1);
|
|
634
|
+
// Dark edge (right + bottom)
|
|
635
|
+
ctx.fillStyle = colors.dark;
|
|
636
|
+
ctx.fillRect(screenX + S - 1, screenY, 1, S);
|
|
637
|
+
ctx.fillRect(screenX, screenY + S - 1, S, 1);
|
|
638
|
+
// Autotile softened edges
|
|
639
|
+
const mask = autotileMask(world, tileWorldX, tileWorldY, block);
|
|
640
|
+
drawAutotileEdges(ctx, screenX, screenY, mask, colors);
|
|
641
|
+
// Tile variation: subtle wave lines
|
|
642
|
+
drawTileVariation(ctx, screenX, screenY, block, world.seed, tileWorldX, tileWorldY);
|
|
643
|
+
ctx.restore();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Special: glass at 30% opacity
|
|
647
|
+
if (block === 'glass') {
|
|
648
|
+
ctx.save();
|
|
649
|
+
ctx.globalAlpha = 0.3;
|
|
650
|
+
ctx.fillStyle = colors.face;
|
|
651
|
+
ctx.fillRect(screenX, screenY, S, S);
|
|
652
|
+
ctx.fillStyle = colors.top;
|
|
653
|
+
ctx.fillRect(screenX, screenY, S, 1);
|
|
654
|
+
ctx.fillStyle = colors.dark;
|
|
655
|
+
ctx.fillRect(screenX + S - 1, screenY, 1, S);
|
|
656
|
+
ctx.fillRect(screenX, screenY + S - 1, S, 1);
|
|
657
|
+
ctx.restore();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Special: lava with brightness pulse
|
|
661
|
+
if (block === 'lava') {
|
|
662
|
+
const pulse = 0.85 + Math.sin(frame * 0.3 + tileWorldX * 0.2) * 0.15;
|
|
663
|
+
ctx.fillStyle = adjustBrightness(colors.face, pulse);
|
|
664
|
+
ctx.fillRect(screenX, screenY, S, S);
|
|
665
|
+
ctx.fillStyle = adjustBrightness(colors.top, pulse * 1.1);
|
|
666
|
+
ctx.fillRect(screenX, screenY, S, 1);
|
|
667
|
+
ctx.fillStyle = adjustBrightness(colors.dark, pulse * 0.9);
|
|
668
|
+
ctx.fillRect(screenX + S - 1, screenY, 1, S);
|
|
669
|
+
ctx.fillRect(screenX, screenY + S - 1, S, 1);
|
|
670
|
+
// Occasional bubble
|
|
671
|
+
if (Math.sin(frame * 0.7 + tileWorldX * 3.1) > 0.9) {
|
|
672
|
+
const bubbleY = screenY - 1 - Math.floor(Math.abs(Math.sin(frame * 0.5 + tileWorldX)) * 3);
|
|
673
|
+
ctx.fillStyle = '#ff9f43';
|
|
674
|
+
ctx.fillRect(screenX + Math.floor(S / 2), bubbleY, 1, 1);
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// Special: leaves with slight sway
|
|
679
|
+
if (block === 'leaves') {
|
|
680
|
+
const sway = Math.sin(frame * 0.1 + tileWorldX * 0.8) * 0.5;
|
|
681
|
+
const sx = screenX + Math.round(sway);
|
|
682
|
+
ctx.fillStyle = colors.face;
|
|
683
|
+
ctx.fillRect(sx, screenY, S, S);
|
|
684
|
+
ctx.fillStyle = colors.top;
|
|
685
|
+
ctx.fillRect(sx, screenY, S, 1);
|
|
686
|
+
ctx.fillStyle = colors.dark;
|
|
687
|
+
ctx.fillRect(sx + S - 1, screenY, 1, S);
|
|
688
|
+
ctx.fillRect(sx, screenY + S - 1, S, 1);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
// ── Default block rendering with autotile + variation ──
|
|
692
|
+
// Main face
|
|
693
|
+
ctx.fillStyle = colors.face;
|
|
694
|
+
ctx.fillRect(screenX, screenY, S, S);
|
|
695
|
+
// Top highlight row
|
|
696
|
+
ctx.fillStyle = colors.top;
|
|
697
|
+
ctx.fillRect(screenX, screenY, S, 1);
|
|
698
|
+
// Right shadow edge
|
|
699
|
+
ctx.fillStyle = colors.dark;
|
|
700
|
+
ctx.fillRect(screenX + S - 1, screenY, 1, S);
|
|
701
|
+
// Bottom shadow edge
|
|
702
|
+
ctx.fillRect(screenX, screenY + S - 1, S, 1);
|
|
703
|
+
// Autotile: soften edges for grass, sand, snow
|
|
704
|
+
if (AUTOTILE_BLOCKS.has(block)) {
|
|
705
|
+
const mask = autotileMask(world, tileWorldX, tileWorldY, block);
|
|
706
|
+
drawAutotileEdges(ctx, screenX, screenY, mask, colors);
|
|
707
|
+
}
|
|
708
|
+
// Tile variation: minor pixel details per block type
|
|
709
|
+
drawTileVariation(ctx, screenX, screenY, block, world.seed, tileWorldX, tileWorldY);
|
|
710
|
+
// Ore fleck rendering
|
|
711
|
+
if (block === 'ore_iron' || block === 'ore_gold' || block === 'ore_diamond') {
|
|
712
|
+
const fleckColor = ORE_FLECK_COLORS[block];
|
|
713
|
+
ctx.fillStyle = fleckColor;
|
|
714
|
+
const fleckSeed = tileWorldX * 31 + tileWorldY * 17;
|
|
715
|
+
const numFlecks = 2 + (fleckSeed % 2);
|
|
716
|
+
for (let i = 0; i < numFlecks; i++) {
|
|
717
|
+
const fx = 3 + ((fleckSeed * (i + 1) * 7) % (S - 6));
|
|
718
|
+
const fy = 3 + ((fleckSeed * (i + 1) * 13) % (S - 6));
|
|
719
|
+
ctx.fillRect(screenX + fx, screenY + fy, 2, 2);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** Draw the sky gradient behind the tile world */
|
|
724
|
+
function drawSky(ctx, panelX, panelY, panelWidth, panelHeight, timeOfDay) {
|
|
725
|
+
const grad = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
|
|
726
|
+
switch (timeOfDay) {
|
|
727
|
+
case 'night':
|
|
728
|
+
grad.addColorStop(0, '#0a0e1a');
|
|
729
|
+
grad.addColorStop(1, '#1a1f3a');
|
|
730
|
+
break;
|
|
731
|
+
case 'sunset':
|
|
732
|
+
grad.addColorStop(0, '#1a1040');
|
|
733
|
+
grad.addColorStop(0.4, '#4a2060');
|
|
734
|
+
grad.addColorStop(0.7, '#c05030');
|
|
735
|
+
grad.addColorStop(1, '#e08040');
|
|
736
|
+
break;
|
|
737
|
+
case 'dawn':
|
|
738
|
+
grad.addColorStop(0, '#1a1a3a');
|
|
739
|
+
grad.addColorStop(0.5, '#4a3060');
|
|
740
|
+
grad.addColorStop(0.8, '#c07050');
|
|
741
|
+
grad.addColorStop(1, '#e0a060');
|
|
742
|
+
break;
|
|
743
|
+
default: // day
|
|
744
|
+
grad.addColorStop(0, '#1a3a5a');
|
|
745
|
+
grad.addColorStop(0.5, '#2a5a8a');
|
|
746
|
+
grad.addColorStop(1, '#4a8aba');
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
ctx.fillStyle = grad;
|
|
750
|
+
ctx.fillRect(panelX, panelY, panelWidth, panelHeight);
|
|
751
|
+
}
|
|
752
|
+
/** Render the visible tile world */
|
|
753
|
+
export function renderTileWorld(ctx, world, panelX, panelY, panelWidth, panelHeight, robotWorldX, frame) {
|
|
754
|
+
// Draw sky background first
|
|
755
|
+
drawSky(ctx, panelX, panelY, panelWidth, panelHeight, world.timeOfDay);
|
|
756
|
+
// Calculate visible tile range from camera position
|
|
757
|
+
const startTileX = Math.floor(world.cameraX / TILE_SIZE);
|
|
758
|
+
const tilesVisibleX = Math.ceil(panelWidth / TILE_SIZE) + 1;
|
|
759
|
+
const tilesVisibleY = Math.ceil(panelHeight / TILE_SIZE) + 1;
|
|
760
|
+
// The Y offset determines which row of the world appears at the top of the panel.
|
|
761
|
+
// We want the surface (around surfaceLevel) to appear roughly in the upper third.
|
|
762
|
+
const viewStartY = Math.max(0, world.surfaceLevel - Math.floor(tilesVisibleY * 0.35));
|
|
763
|
+
// Sub-tile pixel offset for smooth scrolling
|
|
764
|
+
const pixelOffsetX = Math.floor(world.cameraX) % TILE_SIZE;
|
|
765
|
+
// Ensure chunks exist for all visible columns
|
|
766
|
+
for (let dx = -1; dx <= Math.ceil(tilesVisibleX / CHUNK_WIDTH) + 1; dx++) {
|
|
767
|
+
const chunkIdx = tileXToChunkIndex(startTileX) + dx;
|
|
768
|
+
if (!world.chunks.has(chunkIdx)) {
|
|
769
|
+
world.chunks.set(chunkIdx, generateChunk(world, chunkIdx));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// Render visible tiles
|
|
773
|
+
for (let screenTileY = 0; screenTileY < tilesVisibleY; screenTileY++) {
|
|
774
|
+
const tileY = viewStartY + screenTileY;
|
|
775
|
+
if (tileY < 0 || tileY >= WORLD_HEIGHT)
|
|
776
|
+
continue;
|
|
777
|
+
for (let screenTileX = 0; screenTileX <= tilesVisibleX; screenTileX++) {
|
|
778
|
+
const tileX = startTileX + screenTileX;
|
|
779
|
+
const block = getTile(world, tileX, tileY);
|
|
780
|
+
if (block === 'air')
|
|
781
|
+
continue;
|
|
782
|
+
const screenX = panelX + screenTileX * TILE_SIZE - pixelOffsetX;
|
|
783
|
+
const screenY = panelY + screenTileY * TILE_SIZE;
|
|
784
|
+
// Clip to panel bounds
|
|
785
|
+
if (screenX + TILE_SIZE < panelX || screenX > panelX + panelWidth)
|
|
786
|
+
continue;
|
|
787
|
+
if (screenY + TILE_SIZE < panelY || screenY > panelY + panelHeight)
|
|
788
|
+
continue;
|
|
789
|
+
drawBlock(ctx, screenX, screenY, block, frame, tileX, tileY, world);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// Draw some stars in the sky if night
|
|
793
|
+
if (world.timeOfDay === 'night') {
|
|
794
|
+
ctx.fillStyle = '#ffffff';
|
|
795
|
+
const starSeed = world.seed * 13;
|
|
796
|
+
for (let i = 0; i < 30; i++) {
|
|
797
|
+
const sx = panelX + ((starSeed * (i + 1) * 37) % panelWidth);
|
|
798
|
+
const sy = panelY + ((starSeed * (i + 1) * 53) % Math.floor(panelHeight * 0.3));
|
|
799
|
+
const twinkle = Math.sin(frame * 0.2 + i) > 0.3 ? 1 : 0.3;
|
|
800
|
+
ctx.globalAlpha = twinkle;
|
|
801
|
+
ctx.fillRect(sx, sy, 1, 1);
|
|
802
|
+
}
|
|
803
|
+
ctx.globalAlpha = 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// ─── Pre-built Structures ─────────────────────────────────────
|
|
807
|
+
/** Build a house (5 wide, 4 tall brick box with door) */
|
|
808
|
+
function buildHouse(world, baseTileX, baseTileY) {
|
|
809
|
+
// Walls
|
|
810
|
+
for (let dy = 0; dy < 4; dy++) {
|
|
811
|
+
for (let dx = 0; dx < 5; dx++) {
|
|
812
|
+
const isWall = dy === 0 || dy === 3 || dx === 0 || dx === 4;
|
|
813
|
+
const isDoor = dx === 2 && (dy === 2 || dy === 3);
|
|
814
|
+
const isWindow = (dx === 1 || dx === 3) && dy === 1;
|
|
815
|
+
if (isDoor) {
|
|
816
|
+
setTile(world, baseTileX + dx, baseTileY - dy, 'air');
|
|
817
|
+
}
|
|
818
|
+
else if (isWindow) {
|
|
819
|
+
setTile(world, baseTileX + dx, baseTileY - dy, 'glass');
|
|
820
|
+
}
|
|
821
|
+
else if (isWall) {
|
|
822
|
+
setTile(world, baseTileX + dx, baseTileY - dy, 'brick');
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
setTile(world, baseTileX + dx, baseTileY - dy, 'air');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// Roof
|
|
830
|
+
for (let dx = -1; dx <= 5; dx++) {
|
|
831
|
+
setTile(world, baseTileX + dx, baseTileY - 4, 'wood');
|
|
832
|
+
}
|
|
833
|
+
return 'Built a house!';
|
|
834
|
+
}
|
|
835
|
+
/** Build a tower (3 wide, 8 tall brick column) */
|
|
836
|
+
function buildTower(world, baseTileX, baseTileY) {
|
|
837
|
+
for (let dy = 0; dy < 8; dy++) {
|
|
838
|
+
for (let dx = 0; dx < 3; dx++) {
|
|
839
|
+
const isWall = dx === 0 || dx === 2;
|
|
840
|
+
const isTop = dy === 7;
|
|
841
|
+
const isDoor = dx === 1 && dy <= 1;
|
|
842
|
+
if (isDoor) {
|
|
843
|
+
setTile(world, baseTileX + dx, baseTileY - dy, 'air');
|
|
844
|
+
}
|
|
845
|
+
else if (isWall || isTop) {
|
|
846
|
+
setTile(world, baseTileX + dx, baseTileY - dy, 'brick');
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
setTile(world, baseTileX + dx, baseTileY - dy, 'air');
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
// Parapet
|
|
854
|
+
setTile(world, baseTileX - 1, baseTileY - 8, 'stone');
|
|
855
|
+
setTile(world, baseTileX + 3, baseTileY - 8, 'stone');
|
|
856
|
+
return 'Built a tower!';
|
|
857
|
+
}
|
|
858
|
+
/** Build a tree */
|
|
859
|
+
function buildTree(world, baseTileX, baseTileY) {
|
|
860
|
+
// Trunk (4 high)
|
|
861
|
+
for (let dy = 1; dy <= 4; dy++) {
|
|
862
|
+
setTile(world, baseTileX, baseTileY - dy, 'wood');
|
|
863
|
+
}
|
|
864
|
+
// Leaves crown (3x3 at top + 1 above)
|
|
865
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
866
|
+
for (let dy = 4; dy <= 6; dy++) {
|
|
867
|
+
setTile(world, baseTileX + dx, baseTileY - dy, 'leaves');
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
setTile(world, baseTileX, baseTileY - 7, 'leaves');
|
|
871
|
+
return 'Planted a tree!';
|
|
872
|
+
}
|
|
873
|
+
/** Build a bridge (wood planks spanning a gap, 8 tiles wide) */
|
|
874
|
+
function buildBridge(world, baseTileX, baseTileY) {
|
|
875
|
+
for (let dx = 0; dx < 8; dx++) {
|
|
876
|
+
setTile(world, baseTileX + dx, baseTileY, 'wood');
|
|
877
|
+
// Railing
|
|
878
|
+
setTile(world, baseTileX + dx, baseTileY - 1, 'air');
|
|
879
|
+
if (dx === 0 || dx === 7) {
|
|
880
|
+
setTile(world, baseTileX + dx, baseTileY - 1, 'wood');
|
|
881
|
+
setTile(world, baseTileX + dx, baseTileY - 2, 'wood');
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return 'Built a bridge!';
|
|
885
|
+
}
|
|
886
|
+
// ─── Chat Commands ────────────────────────────────────────────
|
|
887
|
+
/** Parse and handle tile world chat commands. Returns response string or null if not a tile command. */
|
|
888
|
+
export function handleTileCommand(text, username, world, robotWorldPixelX) {
|
|
889
|
+
const t = text.trim().toLowerCase();
|
|
890
|
+
const robotTileX = worldXToTile(robotWorldPixelX);
|
|
891
|
+
// Robot stands on the surface — find ground level at robot position
|
|
892
|
+
const robotTileY = findSurfaceY(world, robotTileX);
|
|
893
|
+
// !place <block> <x> <y>
|
|
894
|
+
const placeMatch = t.match(/^!place\s+(\w+)\s+(-?\d+)\s+(-?\d+)$/);
|
|
895
|
+
if (placeMatch) {
|
|
896
|
+
const blockName = placeMatch[1];
|
|
897
|
+
const rx = parseInt(placeMatch[2]);
|
|
898
|
+
const ry = parseInt(placeMatch[3]);
|
|
899
|
+
if (!PLACEABLE_BLOCKS.has(blockName)) {
|
|
900
|
+
return `Can't place "${blockName}". Use: ${[...PLACEABLE_BLOCKS].join(', ')}`;
|
|
901
|
+
}
|
|
902
|
+
if (Math.abs(rx) > 5 || Math.abs(ry) > 5) {
|
|
903
|
+
return 'Too far! Max distance is 5 tiles from the robot.';
|
|
904
|
+
}
|
|
905
|
+
const targetX = robotTileX + rx;
|
|
906
|
+
const targetY = robotTileY - ry; // negative Y = up in world coords
|
|
907
|
+
// Don't place in robot position
|
|
908
|
+
if (rx === 0 && ry === 0) {
|
|
909
|
+
return "Can't place a block on the robot!";
|
|
910
|
+
}
|
|
911
|
+
setTile(world, targetX, targetY, blockName);
|
|
912
|
+
return `${username} placed ${blockName} at (${rx},${ry})`;
|
|
913
|
+
}
|
|
914
|
+
// !break <x> <y>
|
|
915
|
+
const breakMatch = t.match(/^!break\s+(-?\d+)\s+(-?\d+)$/);
|
|
916
|
+
if (breakMatch) {
|
|
917
|
+
const rx = parseInt(breakMatch[1]);
|
|
918
|
+
const ry = parseInt(breakMatch[2]);
|
|
919
|
+
if (Math.abs(rx) > 5 || Math.abs(ry) > 5) {
|
|
920
|
+
return 'Too far! Max distance is 5 tiles.';
|
|
921
|
+
}
|
|
922
|
+
const targetX = robotTileX + rx;
|
|
923
|
+
const targetY = robotTileY - ry;
|
|
924
|
+
const existing = getTile(world, targetX, targetY);
|
|
925
|
+
if (existing === 'air') {
|
|
926
|
+
return 'Nothing to break there!';
|
|
927
|
+
}
|
|
928
|
+
setTile(world, targetX, targetY, 'air');
|
|
929
|
+
return `${username} broke ${existing} at (${rx},${ry})`;
|
|
930
|
+
}
|
|
931
|
+
// !dig
|
|
932
|
+
if (t === '!dig') {
|
|
933
|
+
const below = getTile(world, robotTileX, robotTileY + 1);
|
|
934
|
+
if (below === 'air' || below === 'water' || below === 'lava') {
|
|
935
|
+
return "Nothing solid to dig!";
|
|
936
|
+
}
|
|
937
|
+
setTile(world, robotTileX, robotTileY + 1, 'air');
|
|
938
|
+
return `${username} dug through ${below}!`;
|
|
939
|
+
}
|
|
940
|
+
// !build <structure>
|
|
941
|
+
const buildMatch = t.match(/^!build\s+(\w+)$/);
|
|
942
|
+
if (buildMatch) {
|
|
943
|
+
const structure = buildMatch[1];
|
|
944
|
+
// Place structures relative to robot, on the surface
|
|
945
|
+
const buildX = robotTileX + 2; // offset slightly to the right
|
|
946
|
+
const buildY = robotTileY;
|
|
947
|
+
switch (structure) {
|
|
948
|
+
case 'house': return buildHouse(world, buildX, buildY);
|
|
949
|
+
case 'tower': return buildTower(world, buildX, buildY);
|
|
950
|
+
case 'tree': return buildTree(world, buildX, buildY);
|
|
951
|
+
case 'bridge': return buildBridge(world, buildX, buildY);
|
|
952
|
+
default:
|
|
953
|
+
return `Unknown structure "${structure}". Available: house, tower, tree, bridge`;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// !fill <block> <x1> <y1> <x2> <y2>
|
|
957
|
+
const fillMatch = t.match(/^!fill\s+(\w+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)$/);
|
|
958
|
+
if (fillMatch) {
|
|
959
|
+
const blockName = fillMatch[1];
|
|
960
|
+
const x1 = parseInt(fillMatch[2]);
|
|
961
|
+
const y1 = parseInt(fillMatch[3]);
|
|
962
|
+
const x2 = parseInt(fillMatch[4]);
|
|
963
|
+
const y2 = parseInt(fillMatch[5]);
|
|
964
|
+
if (!PLACEABLE_BLOCKS.has(blockName)) {
|
|
965
|
+
return `Can't fill with "${blockName}". Use: ${[...PLACEABLE_BLOCKS].join(', ')}`;
|
|
966
|
+
}
|
|
967
|
+
const minX = Math.min(x1, x2);
|
|
968
|
+
const maxX = Math.max(x1, x2);
|
|
969
|
+
const minY = Math.min(y1, y2);
|
|
970
|
+
const maxY = Math.max(y1, y2);
|
|
971
|
+
if (maxX - minX > 9 || maxY - minY > 9) {
|
|
972
|
+
return 'Max fill area is 10x10!';
|
|
973
|
+
}
|
|
974
|
+
if (Math.abs(maxX) > 10 || Math.abs(maxY) > 10 || Math.abs(minX) > 10 || Math.abs(minY) > 10) {
|
|
975
|
+
return 'Too far from robot! Keep coordinates within 10.';
|
|
976
|
+
}
|
|
977
|
+
let count = 0;
|
|
978
|
+
for (let ry = minY; ry <= maxY; ry++) {
|
|
979
|
+
for (let rx = minX; rx <= maxX; rx++) {
|
|
980
|
+
const tx = robotTileX + rx;
|
|
981
|
+
const ty = robotTileY - ry;
|
|
982
|
+
setTile(world, tx, ty, blockName);
|
|
983
|
+
count++;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return `${username} filled ${count} blocks with ${blockName}!`;
|
|
987
|
+
}
|
|
988
|
+
// !biome
|
|
989
|
+
if (t === '!biome') {
|
|
990
|
+
const surface = findSurfaceY(world, robotTileX);
|
|
991
|
+
const depth = WORLD_HEIGHT - surface;
|
|
992
|
+
let biomeDesc = 'Overworld (grass terrain)';
|
|
993
|
+
if (surface > world.surfaceLevel + 4)
|
|
994
|
+
biomeDesc = 'Valley (low terrain, possible lake)';
|
|
995
|
+
if (surface < world.surfaceLevel - 4)
|
|
996
|
+
biomeDesc = 'Hills (elevated terrain)';
|
|
997
|
+
return `Biome: ${biomeDesc} | Surface: y=${surface} | Depth to bedrock: ${depth} tiles | Seed: ${world.seed}`;
|
|
998
|
+
}
|
|
999
|
+
// !seed
|
|
1000
|
+
if (t === '!seed') {
|
|
1001
|
+
return `World seed: ${world.seed}`;
|
|
1002
|
+
}
|
|
1003
|
+
// !save
|
|
1004
|
+
if (t === '!save') {
|
|
1005
|
+
saveWorld(world);
|
|
1006
|
+
const modifiedCount = Array.from(world.chunks.values()).filter(c => c.modified).length;
|
|
1007
|
+
return `World saved! (${modifiedCount} modified chunks, ${world.chunks.size} total loaded)`;
|
|
1008
|
+
}
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
/** Find the surface Y (first non-air tile from top) at a given tile X */
|
|
1012
|
+
function findSurfaceY(world, tileX) {
|
|
1013
|
+
for (let y = 0; y < WORLD_HEIGHT; y++) {
|
|
1014
|
+
const block = getTile(world, tileX, y);
|
|
1015
|
+
if (block !== 'air' && block !== 'leaves' && block !== 'water') {
|
|
1016
|
+
return y;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return world.surfaceLevel;
|
|
1020
|
+
}
|
|
1021
|
+
// ─── Tool Registration ────────────────────────────────────────
|
|
1022
|
+
export function registerTileWorldTools() {
|
|
1023
|
+
registerTool({
|
|
1024
|
+
name: 'tile_world_info',
|
|
1025
|
+
description: 'Show current tile world state — seed, camera position, loaded chunks, modified chunks',
|
|
1026
|
+
parameters: {},
|
|
1027
|
+
tier: 'free',
|
|
1028
|
+
execute: async () => {
|
|
1029
|
+
// Try to load world from disk to report on it
|
|
1030
|
+
const world = loadWorld();
|
|
1031
|
+
if (!world) {
|
|
1032
|
+
return 'No tile world exists yet. Start the stream to generate one, or call tile_world_reset.';
|
|
1033
|
+
}
|
|
1034
|
+
const totalChunks = world.chunks.size;
|
|
1035
|
+
const modifiedChunks = Array.from(world.chunks.values()).filter(c => c.modified).length;
|
|
1036
|
+
return [
|
|
1037
|
+
`Tile World Info`,
|
|
1038
|
+
` Seed: ${world.seed}`,
|
|
1039
|
+
` Camera X: ${Math.round(world.cameraX)}px`,
|
|
1040
|
+
` Surface level: ${world.surfaceLevel}`,
|
|
1041
|
+
` Time of day: ${world.timeOfDay}`,
|
|
1042
|
+
` Weather: ${world.weather}`,
|
|
1043
|
+
` Loaded chunks: ${totalChunks}`,
|
|
1044
|
+
` Modified chunks: ${modifiedChunks}`,
|
|
1045
|
+
` Tile size: ${TILE_SIZE}px`,
|
|
1046
|
+
` Chunk size: ${CHUNK_WIDTH}x${WORLD_HEIGHT} tiles`,
|
|
1047
|
+
` World file: ${WORLD_FILE}`,
|
|
1048
|
+
` File exists: ${existsSync(WORLD_FILE)}`,
|
|
1049
|
+
].join('\n');
|
|
1050
|
+
},
|
|
1051
|
+
});
|
|
1052
|
+
registerTool({
|
|
1053
|
+
name: 'tile_world_reset',
|
|
1054
|
+
description: 'Generate a fresh tile world with a new seed. Destroys the current saved world.',
|
|
1055
|
+
parameters: {
|
|
1056
|
+
seed: {
|
|
1057
|
+
type: 'number',
|
|
1058
|
+
description: 'Optional seed for the new world. Random if not provided.',
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
tier: 'free',
|
|
1062
|
+
execute: async (params) => {
|
|
1063
|
+
const seed = typeof params.seed === 'number' ? params.seed : undefined;
|
|
1064
|
+
const world = initTileWorld(seed);
|
|
1065
|
+
saveWorld(world);
|
|
1066
|
+
return `Fresh tile world generated!\n Seed: ${world.seed}\n Surface level: ${world.surfaceLevel}\n Pre-generated chunks: ${world.chunks.size}\n Saved to: ${WORLD_FILE}`;
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
//# sourceMappingURL=tile-world.js.map
|