arbiter-ai 1.0.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/README.md +41 -0
- package/assets/jerom_16x16.png +0 -0
- package/dist/arbiter.d.ts +43 -0
- package/dist/arbiter.js +486 -0
- package/dist/context-analyzer.d.ts +15 -0
- package/dist/context-analyzer.js +603 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +165 -0
- package/dist/orchestrator.d.ts +31 -0
- package/dist/orchestrator.js +227 -0
- package/dist/router.d.ts +187 -0
- package/dist/router.js +1135 -0
- package/dist/router.test.d.ts +15 -0
- package/dist/router.test.js +95 -0
- package/dist/session-persistence.d.ts +9 -0
- package/dist/session-persistence.js +63 -0
- package/dist/session-persistence.test.d.ts +1 -0
- package/dist/session-persistence.test.js +165 -0
- package/dist/sound.d.ts +31 -0
- package/dist/sound.js +50 -0
- package/dist/state.d.ts +72 -0
- package/dist/state.js +107 -0
- package/dist/state.test.d.ts +1 -0
- package/dist/state.test.js +194 -0
- package/dist/test-headless.d.ts +1 -0
- package/dist/test-headless.js +155 -0
- package/dist/tui/index.d.ts +14 -0
- package/dist/tui/index.js +17 -0
- package/dist/tui/layout.d.ts +30 -0
- package/dist/tui/layout.js +200 -0
- package/dist/tui/render.d.ts +57 -0
- package/dist/tui/render.js +266 -0
- package/dist/tui/scene.d.ts +64 -0
- package/dist/tui/scene.js +366 -0
- package/dist/tui/screens/CharacterSelect-termkit.d.ts +18 -0
- package/dist/tui/screens/CharacterSelect-termkit.js +216 -0
- package/dist/tui/screens/ForestIntro-termkit.d.ts +15 -0
- package/dist/tui/screens/ForestIntro-termkit.js +856 -0
- package/dist/tui/screens/GitignoreCheck-termkit.d.ts +14 -0
- package/dist/tui/screens/GitignoreCheck-termkit.js +185 -0
- package/dist/tui/screens/TitleScreen-termkit.d.ts +14 -0
- package/dist/tui/screens/TitleScreen-termkit.js +132 -0
- package/dist/tui/screens/index.d.ts +9 -0
- package/dist/tui/screens/index.js +10 -0
- package/dist/tui/tileset.d.ts +97 -0
- package/dist/tui/tileset.js +237 -0
- package/dist/tui/tui-termkit.d.ts +34 -0
- package/dist/tui/tui-termkit.js +2602 -0
- package/dist/tui/types.d.ts +41 -0
- package/dist/tui/types.js +4 -0
- package/package.json +71 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forest Intro Screen (terminal-kit version)
|
|
3
|
+
*
|
|
4
|
+
* A narrative intro screen between character select and main TUI.
|
|
5
|
+
* Player controls their character with arrow keys to walk through the forest.
|
|
6
|
+
* Uses terminal-kit with Strategy 5 (minimal redraws) for flicker-free rendering.
|
|
7
|
+
*/
|
|
8
|
+
import termKit from 'terminal-kit';
|
|
9
|
+
import { playSfx } from '../../sound.js';
|
|
10
|
+
import { CHAR_HEIGHT, compositeTiles, extractTile, loadTileset, RESET, renderTile, TILE, TILE_SIZE, } from '../tileset.js';
|
|
11
|
+
const term = termKit.terminal;
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Constants
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Scene dimensions: 7 tiles wide x 5 tiles tall
|
|
16
|
+
const SCENE_WIDTH_TILES = 7;
|
|
17
|
+
const SCENE_HEIGHT_TILES = 5;
|
|
18
|
+
const SCENE_WIDTH_CHARS = SCENE_WIDTH_TILES * TILE_SIZE; // 112 chars
|
|
19
|
+
const SCENE_HEIGHT_CHARS = SCENE_HEIGHT_TILES * CHAR_HEIGHT; // 40 rows
|
|
20
|
+
// ANSI codes
|
|
21
|
+
const BOLD = '\x1b[1m';
|
|
22
|
+
const WHITE = '\x1b[97m';
|
|
23
|
+
const DIM = '\x1b[2m';
|
|
24
|
+
// True color theme colors (RGB)
|
|
25
|
+
const COLOR_ARBITER = '\x1b[38;2;100;255;100m'; // Green for THE ARBITER
|
|
26
|
+
const COLOR_WAS = '\x1b[38;2;100;200;255m'; // Blue-cyan for "WAS"
|
|
27
|
+
const COLOR_IS = '\x1b[38;2;200;100;255m'; // Purple for "IS"
|
|
28
|
+
const COLOR_DEATH = '\x1b[38;2;180;50;50m'; // Red for death messages
|
|
29
|
+
const COLOR_RETREAT = '\x1b[38;2;100;180;255m'; // Blue for retreat messages
|
|
30
|
+
// Dialogue box tile indices (2x2 tile message window)
|
|
31
|
+
const DIALOGUE_TILES = {
|
|
32
|
+
TOP_LEFT: 38,
|
|
33
|
+
TOP_RIGHT: 39,
|
|
34
|
+
BOTTOM_LEFT: 48,
|
|
35
|
+
BOTTOM_RIGHT: 49,
|
|
36
|
+
};
|
|
37
|
+
// Death screen tile indices
|
|
38
|
+
const DEATH_TILES = {
|
|
39
|
+
GRAVESTONE: 60,
|
|
40
|
+
SKELETON: 61,
|
|
41
|
+
};
|
|
42
|
+
// Starting position - left side of path
|
|
43
|
+
const START_X = 0;
|
|
44
|
+
const START_Y = 2; // Path row
|
|
45
|
+
// Sign position
|
|
46
|
+
const SIGN_X = 5;
|
|
47
|
+
const SIGN_Y = 1;
|
|
48
|
+
// Rat position (warning NPC)
|
|
49
|
+
const RAT_X = 2;
|
|
50
|
+
const RAT_Y = 1;
|
|
51
|
+
const RAT_TILE = 210;
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Collision System
|
|
54
|
+
// ============================================================================
|
|
55
|
+
/**
|
|
56
|
+
* Tile type constants for collision detection
|
|
57
|
+
*/
|
|
58
|
+
const TILE_TYPE = {
|
|
59
|
+
WALKABLE: 0,
|
|
60
|
+
BLOCKED: 1,
|
|
61
|
+
SIGN: 2,
|
|
62
|
+
EXIT: 3,
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Create a collision map for the forest scene
|
|
66
|
+
*/
|
|
67
|
+
function createCollisionMap() {
|
|
68
|
+
const map = [];
|
|
69
|
+
for (let row = 0; row < SCENE_HEIGHT_TILES; row++) {
|
|
70
|
+
const mapRow = [];
|
|
71
|
+
for (let col = 0; col < SCENE_WIDTH_TILES; col++) {
|
|
72
|
+
let tileType = TILE_TYPE.WALKABLE;
|
|
73
|
+
// Left edge trees (except path row) - blocked
|
|
74
|
+
if (col === 0 && row !== 2) {
|
|
75
|
+
tileType = TILE_TYPE.BLOCKED;
|
|
76
|
+
}
|
|
77
|
+
// Right edge trees (except path row) - blocked
|
|
78
|
+
if (col === 6 && row !== 2) {
|
|
79
|
+
tileType = TILE_TYPE.BLOCKED;
|
|
80
|
+
}
|
|
81
|
+
// Top row: trees are blocked
|
|
82
|
+
if (row === 0) {
|
|
83
|
+
if (col === 0 || col === 1 || col === 5 || col === 6)
|
|
84
|
+
tileType = TILE_TYPE.BLOCKED;
|
|
85
|
+
if (col === 2 || col === 4)
|
|
86
|
+
tileType = TILE_TYPE.BLOCKED;
|
|
87
|
+
}
|
|
88
|
+
// Bottom row: trees are blocked
|
|
89
|
+
if (row === 4) {
|
|
90
|
+
if (col === 0 || col === 6)
|
|
91
|
+
tileType = TILE_TYPE.BLOCKED;
|
|
92
|
+
if (col === 1 || col === 5)
|
|
93
|
+
tileType = TILE_TYPE.BLOCKED;
|
|
94
|
+
}
|
|
95
|
+
// Row 1: trees on edges, sign at col 5
|
|
96
|
+
if (row === 1) {
|
|
97
|
+
if (col === 0 || col === 6)
|
|
98
|
+
tileType = TILE_TYPE.BLOCKED;
|
|
99
|
+
if (col === 5)
|
|
100
|
+
tileType = TILE_TYPE.SIGN;
|
|
101
|
+
}
|
|
102
|
+
// Row 3: trees on edges
|
|
103
|
+
if (row === 3) {
|
|
104
|
+
if (col === 0 || col === 6)
|
|
105
|
+
tileType = TILE_TYPE.BLOCKED;
|
|
106
|
+
}
|
|
107
|
+
// No exit tiles - player must walk off screen to right
|
|
108
|
+
// (formerly: Exit point at right edge of path (row 2, col 6))
|
|
109
|
+
mapRow.push(tileType);
|
|
110
|
+
}
|
|
111
|
+
map.push(mapRow);
|
|
112
|
+
}
|
|
113
|
+
return map;
|
|
114
|
+
}
|
|
115
|
+
// Pre-compute the collision map
|
|
116
|
+
const COLLISION_MAP = createCollisionMap();
|
|
117
|
+
/**
|
|
118
|
+
* Check if a tile position is walkable
|
|
119
|
+
*/
|
|
120
|
+
function isWalkable(x, y) {
|
|
121
|
+
if (x < 0 || y < 0 || x >= SCENE_WIDTH_TILES || y >= SCENE_HEIGHT_TILES) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const tileType = COLLISION_MAP[y][x];
|
|
125
|
+
return tileType !== TILE_TYPE.BLOCKED;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Check if position is off the screen (death zone)
|
|
129
|
+
*/
|
|
130
|
+
function isOffScreen(x, y) {
|
|
131
|
+
return x < 0 || y < 0 || x >= SCENE_WIDTH_TILES || y >= SCENE_HEIGHT_TILES;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Check if position is the exit (no longer used - exit is now off-screen)
|
|
135
|
+
*/
|
|
136
|
+
function _isExit(_x, _y) {
|
|
137
|
+
// Exit is now when player walks off-screen to the right on path row
|
|
138
|
+
// This function kept for compatibility but always returns false
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Check if player is next to the sign (below or left of it)
|
|
143
|
+
*/
|
|
144
|
+
function isNextToSign(x, y) {
|
|
145
|
+
// Below sign: (5, 2)
|
|
146
|
+
if (x === SIGN_X && y === SIGN_Y + 1)
|
|
147
|
+
return true;
|
|
148
|
+
// Left of sign: (4, 1)
|
|
149
|
+
if (x === SIGN_X - 1 && y === SIGN_Y)
|
|
150
|
+
return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Check if player is next to the rat (directly below it)
|
|
155
|
+
*/
|
|
156
|
+
function isNextToRat(x, y) {
|
|
157
|
+
// Below rat: (3, 2)
|
|
158
|
+
return x === RAT_X && y === RAT_Y + 1;
|
|
159
|
+
}
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// Rendering Functions
|
|
162
|
+
// ============================================================================
|
|
163
|
+
/**
|
|
164
|
+
* Apply rainbow colors to text
|
|
165
|
+
*/
|
|
166
|
+
function rainbow(text) {
|
|
167
|
+
const colors = [
|
|
168
|
+
[255, 100, 100], // red
|
|
169
|
+
[255, 200, 100], // orange
|
|
170
|
+
[255, 255, 100], // yellow
|
|
171
|
+
[100, 255, 100], // green
|
|
172
|
+
[100, 255, 255], // cyan
|
|
173
|
+
[100, 100, 255], // blue
|
|
174
|
+
[200, 100, 255], // purple
|
|
175
|
+
[255, 100, 255], // magenta
|
|
176
|
+
];
|
|
177
|
+
let result = '';
|
|
178
|
+
let colorIndex = 0;
|
|
179
|
+
for (const char of text) {
|
|
180
|
+
if (char === ' ') {
|
|
181
|
+
result += char;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const [r, g, b] = colors[colorIndex % colors.length];
|
|
185
|
+
result += `\x1b[38;2;${r};${g};${b}m${char}`;
|
|
186
|
+
colorIndex++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result + RESET;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Strip ANSI escape codes from a string to get visible length
|
|
193
|
+
*/
|
|
194
|
+
function stripAnsi(str) {
|
|
195
|
+
// eslint-disable-next-line no-control-regex
|
|
196
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Create the forest scene layout
|
|
200
|
+
*/
|
|
201
|
+
function createForestScene(characterTile, playerX, playerY) {
|
|
202
|
+
const scene = [];
|
|
203
|
+
for (let row = 0; row < SCENE_HEIGHT_TILES; row++) {
|
|
204
|
+
const sceneRow = [];
|
|
205
|
+
for (let col = 0; col < SCENE_WIDTH_TILES; col++) {
|
|
206
|
+
let tile = TILE.GRASS;
|
|
207
|
+
// Left edge trees (except path row)
|
|
208
|
+
if (col === 0 && row !== 2) {
|
|
209
|
+
tile = TILE.PINE_TREE;
|
|
210
|
+
}
|
|
211
|
+
// Right edge trees (except path row)
|
|
212
|
+
if (col === 6 && row !== 2) {
|
|
213
|
+
tile = TILE.PINE_TREE;
|
|
214
|
+
}
|
|
215
|
+
// Top row: mix of trees
|
|
216
|
+
if (row === 0) {
|
|
217
|
+
if (col === 0 || col === 1 || col === 5 || col === 6)
|
|
218
|
+
tile = TILE.PINE_TREE;
|
|
219
|
+
if (col === 2 || col === 4)
|
|
220
|
+
tile = TILE.BARE_TREE;
|
|
221
|
+
}
|
|
222
|
+
// Bottom row: mix of trees
|
|
223
|
+
if (row === 4) {
|
|
224
|
+
if (col === 0 || col === 6)
|
|
225
|
+
tile = TILE.PINE_TREE;
|
|
226
|
+
if (col === 1 || col === 5)
|
|
227
|
+
tile = TILE.BARE_TREE;
|
|
228
|
+
}
|
|
229
|
+
// Add signpost near right edge, above path
|
|
230
|
+
if (row === 1 && col === 5) {
|
|
231
|
+
tile = 63; // Signpost tile
|
|
232
|
+
}
|
|
233
|
+
// Add rat NPC above path
|
|
234
|
+
if (row === RAT_Y && col === RAT_X) {
|
|
235
|
+
tile = RAT_TILE;
|
|
236
|
+
}
|
|
237
|
+
// Middle path row (row 2) - sparse grass for path ALL THE WAY THROUGH
|
|
238
|
+
if (row === 2) {
|
|
239
|
+
tile = TILE.GRASS_SPARSE;
|
|
240
|
+
}
|
|
241
|
+
// Place character at player position
|
|
242
|
+
if (row === playerY &&
|
|
243
|
+
col === playerX &&
|
|
244
|
+
playerX >= 0 &&
|
|
245
|
+
playerX < SCENE_WIDTH_TILES &&
|
|
246
|
+
playerY >= 0 &&
|
|
247
|
+
playerY < SCENE_HEIGHT_TILES) {
|
|
248
|
+
tile = characterTile;
|
|
249
|
+
}
|
|
250
|
+
sceneRow.push(tile);
|
|
251
|
+
}
|
|
252
|
+
scene.push(sceneRow);
|
|
253
|
+
}
|
|
254
|
+
return scene;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Get the background tile index at a position (what the scene would have without the player)
|
|
258
|
+
*/
|
|
259
|
+
function getBackgroundTileAt(row, col) {
|
|
260
|
+
let tile = TILE.GRASS;
|
|
261
|
+
// Left edge trees (except path row)
|
|
262
|
+
if (col === 0 && row !== 2) {
|
|
263
|
+
tile = TILE.PINE_TREE;
|
|
264
|
+
}
|
|
265
|
+
// Right edge trees (except path row)
|
|
266
|
+
if (col === 6 && row !== 2) {
|
|
267
|
+
tile = TILE.PINE_TREE;
|
|
268
|
+
}
|
|
269
|
+
// Top row: mix of trees
|
|
270
|
+
if (row === 0) {
|
|
271
|
+
if (col === 0 || col === 1 || col === 5 || col === 6)
|
|
272
|
+
tile = TILE.PINE_TREE;
|
|
273
|
+
if (col === 2 || col === 4)
|
|
274
|
+
tile = TILE.BARE_TREE;
|
|
275
|
+
}
|
|
276
|
+
// Bottom row: mix of trees
|
|
277
|
+
if (row === 4) {
|
|
278
|
+
if (col === 0 || col === 6)
|
|
279
|
+
tile = TILE.PINE_TREE;
|
|
280
|
+
if (col === 1 || col === 5)
|
|
281
|
+
tile = TILE.BARE_TREE;
|
|
282
|
+
}
|
|
283
|
+
// Add signpost near right edge, above path
|
|
284
|
+
if (row === 1 && col === 5) {
|
|
285
|
+
tile = 63; // Signpost tile
|
|
286
|
+
}
|
|
287
|
+
// Add rat NPC above path (on bare grass)
|
|
288
|
+
if (row === RAT_Y && col === RAT_X) {
|
|
289
|
+
tile = TILE.GRASS; // Rat sits on bare grass, not sparse
|
|
290
|
+
}
|
|
291
|
+
// Middle path row (row 2) - sparse grass for path ALL THE WAY THROUGH
|
|
292
|
+
if (row === 2) {
|
|
293
|
+
tile = TILE.GRASS_SPARSE;
|
|
294
|
+
}
|
|
295
|
+
return tile;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Render the forest scene to an array of ANSI strings (one per row)
|
|
299
|
+
*/
|
|
300
|
+
function renderForestScene(tileset, characterTile, playerX, playerY) {
|
|
301
|
+
const trailTile = extractTile(tileset, TILE.GRASS_SPARSE);
|
|
302
|
+
// Create and render forest scene
|
|
303
|
+
const scene = createForestScene(characterTile, playerX, playerY);
|
|
304
|
+
// Pre-render all tiles
|
|
305
|
+
const renderedTiles = [];
|
|
306
|
+
for (let row = 0; row < scene.length; row++) {
|
|
307
|
+
const renderedRow = [];
|
|
308
|
+
for (let col = 0; col < scene[row].length; col++) {
|
|
309
|
+
const tileIndex = scene[row][col];
|
|
310
|
+
let pixels = extractTile(tileset, tileIndex);
|
|
311
|
+
// Composite characters/objects on appropriate background
|
|
312
|
+
if (tileIndex >= 80) {
|
|
313
|
+
// Check if this is the player character position
|
|
314
|
+
if (row === playerY && col === playerX) {
|
|
315
|
+
// Get the actual background tile at this position
|
|
316
|
+
const bgTileIndex = getBackgroundTileAt(row, col);
|
|
317
|
+
const bgTile = extractTile(tileset, bgTileIndex);
|
|
318
|
+
pixels = compositeTiles(pixels, bgTile, 1);
|
|
319
|
+
}
|
|
320
|
+
else if (row === RAT_Y && col === RAT_X) {
|
|
321
|
+
// Rat uses bare grass
|
|
322
|
+
const grassTile = extractTile(tileset, TILE.GRASS);
|
|
323
|
+
pixels = compositeTiles(pixels, grassTile, 1);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
// Other objects (like signpost) use sparse grass
|
|
327
|
+
pixels = compositeTiles(pixels, trailTile, 1);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
renderedRow.push(renderTile(pixels));
|
|
331
|
+
}
|
|
332
|
+
renderedTiles.push(renderedRow);
|
|
333
|
+
}
|
|
334
|
+
// Build output lines
|
|
335
|
+
const lines = [];
|
|
336
|
+
for (let tileRow = 0; tileRow < scene.length; tileRow++) {
|
|
337
|
+
for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
|
|
338
|
+
let line = '';
|
|
339
|
+
for (let tileCol = 0; tileCol < scene[tileRow].length; tileCol++) {
|
|
340
|
+
line += renderedTiles[tileRow][tileCol][charRow];
|
|
341
|
+
}
|
|
342
|
+
lines.push(line);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return lines;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Create middle fill row for dialogue box
|
|
349
|
+
*/
|
|
350
|
+
function createMiddleFill(leftTile, charRow) {
|
|
351
|
+
const pixelRowTop = charRow * 2;
|
|
352
|
+
const pixelRowBot = pixelRowTop + 1;
|
|
353
|
+
let result = '';
|
|
354
|
+
const sampleX = 8; // Middle column
|
|
355
|
+
for (let x = 0; x < 16; x++) {
|
|
356
|
+
const topPixel = leftTile[pixelRowTop][sampleX];
|
|
357
|
+
const botPixel = leftTile[pixelRowBot]?.[sampleX] || topPixel;
|
|
358
|
+
result += `\x1b[48;2;${topPixel.r};${topPixel.g};${topPixel.b}m`;
|
|
359
|
+
result += `\x1b[38;2;${botPixel.r};${botPixel.g};${botPixel.b}m`;
|
|
360
|
+
result += '\u2584'; // Lower half block
|
|
361
|
+
}
|
|
362
|
+
result += RESET;
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Wrap text with consistent background color
|
|
367
|
+
*/
|
|
368
|
+
function wrapTextWithBg(text, bgColor) {
|
|
369
|
+
const bgMaintained = text.replace(/\x1b\[0m/g, `\x1b[0m${bgColor}`);
|
|
370
|
+
return bgColor + bgMaintained + RESET;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Render the dialogue box overlay as an array of strings
|
|
374
|
+
*/
|
|
375
|
+
function renderDialogueBox(tileset) {
|
|
376
|
+
const dialogueBoxWidthTiles = 5;
|
|
377
|
+
// Extract dialogue tiles
|
|
378
|
+
const topLeft = extractTile(tileset, DIALOGUE_TILES.TOP_LEFT);
|
|
379
|
+
const topRight = extractTile(tileset, DIALOGUE_TILES.TOP_RIGHT);
|
|
380
|
+
const bottomLeft = extractTile(tileset, DIALOGUE_TILES.BOTTOM_LEFT);
|
|
381
|
+
const bottomRight = extractTile(tileset, DIALOGUE_TILES.BOTTOM_RIGHT);
|
|
382
|
+
const tlRendered = renderTile(topLeft);
|
|
383
|
+
const trRendered = renderTile(topRight);
|
|
384
|
+
const blRendered = renderTile(bottomLeft);
|
|
385
|
+
const brRendered = renderTile(bottomRight);
|
|
386
|
+
// Create middle fill rows
|
|
387
|
+
const middleTopRendered = [];
|
|
388
|
+
const middleBottomRendered = [];
|
|
389
|
+
for (let row = 0; row < CHAR_HEIGHT; row++) {
|
|
390
|
+
middleTopRendered.push(createMiddleFill(topLeft, row));
|
|
391
|
+
middleBottomRendered.push(createMiddleFill(bottomLeft, row));
|
|
392
|
+
}
|
|
393
|
+
const middleTiles = Math.max(0, dialogueBoxWidthTiles - 2);
|
|
394
|
+
const interiorWidth = middleTiles * 16; // 3 tiles * 16 = 48 chars
|
|
395
|
+
// Build dialogue box lines
|
|
396
|
+
const boxLines = [];
|
|
397
|
+
// Top row of dialogue box tiles
|
|
398
|
+
for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
|
|
399
|
+
let line = tlRendered[charRow];
|
|
400
|
+
for (let m = 0; m < middleTiles; m++) {
|
|
401
|
+
line += middleTopRendered[charRow];
|
|
402
|
+
}
|
|
403
|
+
line += trRendered[charRow];
|
|
404
|
+
boxLines.push(line);
|
|
405
|
+
}
|
|
406
|
+
// Bottom row of dialogue box tiles
|
|
407
|
+
for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
|
|
408
|
+
let line = blRendered[charRow];
|
|
409
|
+
for (let m = 0; m < middleTiles; m++) {
|
|
410
|
+
line += middleBottomRendered[charRow];
|
|
411
|
+
}
|
|
412
|
+
line += brRendered[charRow];
|
|
413
|
+
boxLines.push(line);
|
|
414
|
+
}
|
|
415
|
+
// Sample background color from dialogue tile center
|
|
416
|
+
const bgSamplePixel = topLeft[8][8];
|
|
417
|
+
const textBgColor = `\x1b[48;2;${bgSamplePixel.r};${bgSamplePixel.g};${bgSamplePixel.b}m`;
|
|
418
|
+
// The Arbiter wisdom text
|
|
419
|
+
const textLines = [
|
|
420
|
+
`${WHITE}You approach the lair of`,
|
|
421
|
+
'',
|
|
422
|
+
`${BOLD}${COLOR_ARBITER}THE ARBITER`,
|
|
423
|
+
`${WHITE}OF THAT WHICH ${COLOR_WAS}WAS${WHITE},`,
|
|
424
|
+
`${WHITE}THAT WHICH ${COLOR_IS}IS${WHITE},`,
|
|
425
|
+
`${WHITE}AND THAT WHICH ${rainbow('SHALL COME TO BE')}`,
|
|
426
|
+
];
|
|
427
|
+
// Center text in the dialogue box
|
|
428
|
+
const boxHeight = CHAR_HEIGHT * 2;
|
|
429
|
+
const textStartOffset = Math.floor((boxHeight - textLines.length) / 2);
|
|
430
|
+
// Overlay text onto the box
|
|
431
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
432
|
+
const boxLineIndex = textStartOffset + i;
|
|
433
|
+
if (boxLineIndex >= 0 && boxLineIndex < boxLines.length) {
|
|
434
|
+
const line = textLines[i];
|
|
435
|
+
const visibleLength = stripAnsi(line).length;
|
|
436
|
+
const padding = Math.max(0, Math.floor((interiorWidth - visibleLength) / 2));
|
|
437
|
+
const rightPadding = Math.max(0, interiorWidth - padding - visibleLength);
|
|
438
|
+
const textContent = ' '.repeat(padding) + line + ' '.repeat(rightPadding);
|
|
439
|
+
const textWithBg = wrapTextWithBg(textContent, textBgColor);
|
|
440
|
+
const isTopHalf = boxLineIndex < CHAR_HEIGHT;
|
|
441
|
+
const charRow = isTopHalf ? boxLineIndex : boxLineIndex - CHAR_HEIGHT;
|
|
442
|
+
const leftBorder = isTopHalf ? tlRendered[charRow] : blRendered[charRow];
|
|
443
|
+
const rightBorder = isTopHalf ? trRendered[charRow] : brRendered[charRow];
|
|
444
|
+
boxLines[boxLineIndex] = leftBorder + textWithBg + rightBorder;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return boxLines;
|
|
448
|
+
}
|
|
449
|
+
// Rat dialogue colors
|
|
450
|
+
const _COLOR_RAT = '\x1b[38;2;180;140;100m'; // Brown for rat
|
|
451
|
+
const COLOR_RAT_EEK = '\x1b[38;2;255;200;100m'; // Yellow for eek
|
|
452
|
+
/**
|
|
453
|
+
* Render the rat dialogue box overlay as an array of strings
|
|
454
|
+
*/
|
|
455
|
+
function renderRatDialogueBox(tileset) {
|
|
456
|
+
const dialogueBoxWidthTiles = 6; // Wider for more text
|
|
457
|
+
// Extract dialogue tiles
|
|
458
|
+
const topLeft = extractTile(tileset, DIALOGUE_TILES.TOP_LEFT);
|
|
459
|
+
const topRight = extractTile(tileset, DIALOGUE_TILES.TOP_RIGHT);
|
|
460
|
+
const bottomLeft = extractTile(tileset, DIALOGUE_TILES.BOTTOM_LEFT);
|
|
461
|
+
const bottomRight = extractTile(tileset, DIALOGUE_TILES.BOTTOM_RIGHT);
|
|
462
|
+
const tlRendered = renderTile(topLeft);
|
|
463
|
+
const trRendered = renderTile(topRight);
|
|
464
|
+
const blRendered = renderTile(bottomLeft);
|
|
465
|
+
const brRendered = renderTile(bottomRight);
|
|
466
|
+
// Create middle fill rows
|
|
467
|
+
const middleTopRendered = [];
|
|
468
|
+
const middleBottomRendered = [];
|
|
469
|
+
for (let row = 0; row < CHAR_HEIGHT; row++) {
|
|
470
|
+
middleTopRendered.push(createMiddleFill(topLeft, row));
|
|
471
|
+
middleBottomRendered.push(createMiddleFill(bottomLeft, row));
|
|
472
|
+
}
|
|
473
|
+
const middleTiles = Math.max(0, dialogueBoxWidthTiles - 2);
|
|
474
|
+
const interiorWidth = middleTiles * 16; // 4 tiles * 16 = 64 chars
|
|
475
|
+
// Build dialogue box lines (2 rows of tiles = 16 char rows)
|
|
476
|
+
const boxLines = [];
|
|
477
|
+
// Top row of dialogue box tiles
|
|
478
|
+
for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
|
|
479
|
+
let line = tlRendered[charRow];
|
|
480
|
+
for (let m = 0; m < middleTiles; m++) {
|
|
481
|
+
line += middleTopRendered[charRow];
|
|
482
|
+
}
|
|
483
|
+
line += trRendered[charRow];
|
|
484
|
+
boxLines.push(line);
|
|
485
|
+
}
|
|
486
|
+
// Bottom row of dialogue box tiles
|
|
487
|
+
for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
|
|
488
|
+
let line = blRendered[charRow];
|
|
489
|
+
for (let m = 0; m < middleTiles; m++) {
|
|
490
|
+
line += middleBottomRendered[charRow];
|
|
491
|
+
}
|
|
492
|
+
line += brRendered[charRow];
|
|
493
|
+
boxLines.push(line);
|
|
494
|
+
}
|
|
495
|
+
// Sample background color from dialogue tile center
|
|
496
|
+
const bgSamplePixel = topLeft[8][8];
|
|
497
|
+
const textBgColor = `\x1b[48;2;${bgSamplePixel.r};${bgSamplePixel.g};${bgSamplePixel.b}m`;
|
|
498
|
+
// Rat dialogue text (compressed)
|
|
499
|
+
const textLines = [
|
|
500
|
+
`${COLOR_RAT_EEK}*eek!* ${WHITE}Heed my example!`,
|
|
501
|
+
'',
|
|
502
|
+
`${WHITE}The Arbiter rewards those who sacrifice first -`,
|
|
503
|
+
`${WHITE}who toil over their requirements until every detail is known.`,
|
|
504
|
+
'',
|
|
505
|
+
`${WHITE}If you offer only scattered thoughts, turn ye back!`,
|
|
506
|
+
`${WHITE}He might ruin your code, or turn you into a rat!`,
|
|
507
|
+
];
|
|
508
|
+
// Center text in the dialogue box
|
|
509
|
+
const boxHeight = CHAR_HEIGHT * 2; // 2 tile rows
|
|
510
|
+
const textStartOffset = Math.floor((boxHeight - textLines.length) / 2);
|
|
511
|
+
// Overlay text onto the box
|
|
512
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
513
|
+
const boxLineIndex = textStartOffset + i;
|
|
514
|
+
if (boxLineIndex >= 0 && boxLineIndex < boxLines.length) {
|
|
515
|
+
const line = textLines[i];
|
|
516
|
+
const visibleLength = stripAnsi(line).length;
|
|
517
|
+
const padding = Math.max(0, Math.floor((interiorWidth - visibleLength) / 2));
|
|
518
|
+
const rightPadding = Math.max(0, interiorWidth - padding - visibleLength);
|
|
519
|
+
const textContent = ' '.repeat(padding) + line + ' '.repeat(rightPadding);
|
|
520
|
+
const textWithBg = wrapTextWithBg(textContent, textBgColor);
|
|
521
|
+
// Determine which row we're in (0 or 1)
|
|
522
|
+
const tileRow = Math.floor(boxLineIndex / CHAR_HEIGHT);
|
|
523
|
+
const charRow = boxLineIndex % CHAR_HEIGHT;
|
|
524
|
+
const leftBorder = tileRow === 0 ? tlRendered[charRow] : blRendered[charRow];
|
|
525
|
+
const rightBorder = tileRow === 0 ? trRendered[charRow] : brRendered[charRow];
|
|
526
|
+
boxLines[boxLineIndex] = leftBorder + textWithBg + rightBorder;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return boxLines;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Render the death scene
|
|
533
|
+
*/
|
|
534
|
+
function renderDeathScene(tileset) {
|
|
535
|
+
const DEATH_WIDTH = 3;
|
|
536
|
+
const DEATH_HEIGHT = 2;
|
|
537
|
+
const grassTile = extractTile(tileset, TILE.GRASS_SPARSE);
|
|
538
|
+
const gravestoneTile = extractTile(tileset, DEATH_TILES.GRAVESTONE);
|
|
539
|
+
const skeletonTile = extractTile(tileset, DEATH_TILES.SKELETON);
|
|
540
|
+
const gravestoneComposite = compositeTiles(gravestoneTile, grassTile, 1);
|
|
541
|
+
const skeletonComposite = compositeTiles(skeletonTile, grassTile, 1);
|
|
542
|
+
const scene = [
|
|
543
|
+
[grassTile, gravestoneComposite, grassTile],
|
|
544
|
+
[grassTile, skeletonComposite, grassTile],
|
|
545
|
+
];
|
|
546
|
+
const renderedTiles = [];
|
|
547
|
+
for (let row = 0; row < DEATH_HEIGHT; row++) {
|
|
548
|
+
const renderedRow = [];
|
|
549
|
+
for (let col = 0; col < DEATH_WIDTH; col++) {
|
|
550
|
+
renderedRow.push(renderTile(scene[row][col]));
|
|
551
|
+
}
|
|
552
|
+
renderedTiles.push(renderedRow);
|
|
553
|
+
}
|
|
554
|
+
const lines = [];
|
|
555
|
+
for (let tileRow = 0; tileRow < DEATH_HEIGHT; tileRow++) {
|
|
556
|
+
for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
|
|
557
|
+
let line = '';
|
|
558
|
+
for (let tileCol = 0; tileCol < DEATH_WIDTH; tileCol++) {
|
|
559
|
+
line += renderedTiles[tileRow][tileCol][charRow];
|
|
560
|
+
}
|
|
561
|
+
lines.push(line);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return lines;
|
|
565
|
+
}
|
|
566
|
+
// ============================================================================
|
|
567
|
+
// Main Export
|
|
568
|
+
// ============================================================================
|
|
569
|
+
/**
|
|
570
|
+
* Shows the forest intro screen using terminal-kit with Strategy 5 (minimal redraws)
|
|
571
|
+
*
|
|
572
|
+
* @param selectedCharacter - The tile index of the selected character (190-197)
|
|
573
|
+
* @returns Promise<'success' | 'death'> - 'success' when player exits right after seeing sign, 'death' if they die
|
|
574
|
+
*/
|
|
575
|
+
export async function showForestIntro(selectedCharacter) {
|
|
576
|
+
// Load tileset before entering the Promise
|
|
577
|
+
const tileset = await loadTileset();
|
|
578
|
+
return new Promise((resolve) => {
|
|
579
|
+
// Initialize terminal
|
|
580
|
+
term.fullscreen(true);
|
|
581
|
+
term.hideCursor();
|
|
582
|
+
term.grabInput(true);
|
|
583
|
+
// State
|
|
584
|
+
const state = {
|
|
585
|
+
playerX: START_X,
|
|
586
|
+
playerY: START_Y,
|
|
587
|
+
phase: 'walking',
|
|
588
|
+
hasSeenSign: false,
|
|
589
|
+
hasSeenRat: false,
|
|
590
|
+
};
|
|
591
|
+
// Change tracker for minimal redraws
|
|
592
|
+
const tracker = {
|
|
593
|
+
lastPlayerX: -1,
|
|
594
|
+
lastPlayerY: -1,
|
|
595
|
+
lastPhase: 'walking',
|
|
596
|
+
lastShowMessage: false,
|
|
597
|
+
lastShowRatMessage: false,
|
|
598
|
+
};
|
|
599
|
+
// Calculate centering offsets
|
|
600
|
+
let width = 180;
|
|
601
|
+
let height = 50;
|
|
602
|
+
if (typeof term.width === 'number' && Number.isFinite(term.width) && term.width > 0) {
|
|
603
|
+
width = term.width;
|
|
604
|
+
}
|
|
605
|
+
if (typeof term.height === 'number' && Number.isFinite(term.height) && term.height > 0) {
|
|
606
|
+
height = term.height;
|
|
607
|
+
}
|
|
608
|
+
const sceneOffsetX = Math.max(1, Math.floor((width - SCENE_WIDTH_CHARS) / 2));
|
|
609
|
+
const sceneOffsetY = Math.max(1, Math.floor((height - SCENE_HEIGHT_CHARS - 4) / 2));
|
|
610
|
+
/**
|
|
611
|
+
* Draw the forest scene (only if changed)
|
|
612
|
+
*/
|
|
613
|
+
function drawScene() {
|
|
614
|
+
const showMessage = isNextToSign(state.playerX, state.playerY);
|
|
615
|
+
const showRatMessage = isNextToRat(state.playerX, state.playerY);
|
|
616
|
+
// Track if player has seen the sign or rat
|
|
617
|
+
if (showMessage) {
|
|
618
|
+
state.hasSeenSign = true;
|
|
619
|
+
}
|
|
620
|
+
if (showRatMessage) {
|
|
621
|
+
state.hasSeenRat = true;
|
|
622
|
+
}
|
|
623
|
+
// Check if anything changed
|
|
624
|
+
if (state.playerX === tracker.lastPlayerX &&
|
|
625
|
+
state.playerY === tracker.lastPlayerY &&
|
|
626
|
+
state.phase === tracker.lastPhase &&
|
|
627
|
+
showMessage === tracker.lastShowMessage &&
|
|
628
|
+
showRatMessage === tracker.lastShowRatMessage) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
// Detect if dialogue just appeared (for sound effects)
|
|
632
|
+
const signDialogueJustAppeared = showMessage && !tracker.lastShowMessage;
|
|
633
|
+
const ratDialogueJustAppeared = showRatMessage && !tracker.lastShowRatMessage;
|
|
634
|
+
tracker.lastPlayerX = state.playerX;
|
|
635
|
+
tracker.lastPlayerY = state.playerY;
|
|
636
|
+
tracker.lastPhase = state.phase;
|
|
637
|
+
tracker.lastShowMessage = showMessage;
|
|
638
|
+
tracker.lastShowRatMessage = showRatMessage;
|
|
639
|
+
// Play dialogue appearance sounds
|
|
640
|
+
if (signDialogueJustAppeared) {
|
|
641
|
+
playSfx('quickNotice');
|
|
642
|
+
}
|
|
643
|
+
else if (ratDialogueJustAppeared) {
|
|
644
|
+
playSfx('quickNotice');
|
|
645
|
+
}
|
|
646
|
+
const sceneLines = renderForestScene(tileset, selectedCharacter, state.playerX, state.playerY);
|
|
647
|
+
// Write scene lines
|
|
648
|
+
for (let i = 0; i < sceneLines.length; i++) {
|
|
649
|
+
term.moveTo(sceneOffsetX, sceneOffsetY + i);
|
|
650
|
+
process.stdout.write(sceneLines[i] + RESET);
|
|
651
|
+
}
|
|
652
|
+
// Show dialogue box at bottom of scene if next to sign
|
|
653
|
+
if (showMessage) {
|
|
654
|
+
const dialogueLines = renderDialogueBox(tileset);
|
|
655
|
+
// Center dialogue box horizontally: 5 tiles = 80 chars, scene = 112 chars
|
|
656
|
+
// (112 - 80) / 2 = 16 chars offset from scene start
|
|
657
|
+
const dialogueOffsetX = sceneOffsetX + Math.floor((SCENE_WIDTH_CHARS - 80) / 2);
|
|
658
|
+
// Position dialogue to cover bottom 3 tile rows of scene
|
|
659
|
+
// 3 tiles * 8 rows/tile = 24 rows, so start at: sceneHeight - 24
|
|
660
|
+
const dialogueOffsetY = sceneOffsetY + (SCENE_HEIGHT_CHARS - 16);
|
|
661
|
+
for (let i = 0; i < dialogueLines.length; i++) {
|
|
662
|
+
term.moveTo(dialogueOffsetX, dialogueOffsetY + i);
|
|
663
|
+
process.stdout.write(dialogueLines[i] + RESET);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
else if (showRatMessage) {
|
|
667
|
+
// Show rat dialogue box
|
|
668
|
+
const dialogueLines = renderRatDialogueBox(tileset);
|
|
669
|
+
// Center dialogue box horizontally: 6 tiles = 96 chars, scene = 112 chars
|
|
670
|
+
const dialogueOffsetX = sceneOffsetX + Math.floor((SCENE_WIDTH_CHARS - 96) / 2);
|
|
671
|
+
// Position dialogue to cover bottom portion of scene (2 tile rows = 16 char rows)
|
|
672
|
+
const dialogueOffsetY = sceneOffsetY + (SCENE_HEIGHT_CHARS - 16);
|
|
673
|
+
for (let i = 0; i < dialogueLines.length; i++) {
|
|
674
|
+
term.moveTo(dialogueOffsetX, dialogueOffsetY + i);
|
|
675
|
+
process.stdout.write(dialogueLines[i] + RESET);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
// Show hint text at bottom
|
|
680
|
+
const hintY = sceneOffsetY + SCENE_HEIGHT_CHARS + 1;
|
|
681
|
+
term.moveTo(sceneOffsetX, hintY);
|
|
682
|
+
// Clear the line first
|
|
683
|
+
process.stdout.write(' '.repeat(SCENE_WIDTH_CHARS));
|
|
684
|
+
term.moveTo(sceneOffsetX, hintY);
|
|
685
|
+
let hintText = `${DIM}Use arrow keys to move. Walk to the right to find the Arbiter.${RESET}`;
|
|
686
|
+
if (state.hasSeenSign) {
|
|
687
|
+
hintText = `${DIM}Continue to the right to enter the Arbiter's lair.${RESET}`;
|
|
688
|
+
}
|
|
689
|
+
process.stdout.write(hintText);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Draw the death screen
|
|
694
|
+
*/
|
|
695
|
+
function drawDeathScreen() {
|
|
696
|
+
// Clear screen first
|
|
697
|
+
term.clear();
|
|
698
|
+
const deathLines = renderDeathScene(tileset);
|
|
699
|
+
// Center the death scene
|
|
700
|
+
const deathWidth = 3 * TILE_SIZE; // 48 chars
|
|
701
|
+
const deathHeight = deathLines.length;
|
|
702
|
+
const deathOffsetX = Math.max(1, Math.floor((width - deathWidth) / 2));
|
|
703
|
+
const deathOffsetY = Math.max(1, Math.floor((height - deathHeight - 6) / 2));
|
|
704
|
+
// Draw death scene tiles
|
|
705
|
+
for (let i = 0; i < deathLines.length; i++) {
|
|
706
|
+
term.moveTo(deathOffsetX, deathOffsetY + i);
|
|
707
|
+
process.stdout.write(deathLines[i] + RESET);
|
|
708
|
+
}
|
|
709
|
+
// Death messages
|
|
710
|
+
const msgY = deathOffsetY + deathHeight + 2;
|
|
711
|
+
const msg1 = `${COLOR_DEATH}${BOLD}You strayed from the path.${RESET}`;
|
|
712
|
+
const msg2 = `${COLOR_DEATH}The forest claims another soul.${RESET}`;
|
|
713
|
+
const msg3 = `${DIM}Press y to try again...${RESET}`;
|
|
714
|
+
term.moveTo(Math.max(1, Math.floor((width - 26) / 2)), msgY);
|
|
715
|
+
process.stdout.write(msg1);
|
|
716
|
+
term.moveTo(Math.max(1, Math.floor((width - 32) / 2)), msgY + 1);
|
|
717
|
+
process.stdout.write(msg2);
|
|
718
|
+
term.moveTo(Math.max(1, Math.floor((width - 22) / 2)), msgY + 3);
|
|
719
|
+
process.stdout.write(msg3);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Draw the retreat screen (turned back)
|
|
723
|
+
*/
|
|
724
|
+
function drawRetreatScreen() {
|
|
725
|
+
// Clear screen first
|
|
726
|
+
term.clear();
|
|
727
|
+
// Render the player character on grass
|
|
728
|
+
const grassTile = extractTile(tileset, TILE.GRASS_SPARSE);
|
|
729
|
+
const characterTile = extractTile(tileset, selectedCharacter);
|
|
730
|
+
const characterComposite = compositeTiles(characterTile, grassTile, 1);
|
|
731
|
+
const scene = [[grassTile, characterComposite, grassTile]];
|
|
732
|
+
const renderedTiles = [];
|
|
733
|
+
for (let col = 0; col < 3; col++) {
|
|
734
|
+
renderedTiles.push([renderTile(scene[0][col])]);
|
|
735
|
+
}
|
|
736
|
+
const lines = [];
|
|
737
|
+
for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
|
|
738
|
+
let line = '';
|
|
739
|
+
for (let col = 0; col < 3; col++) {
|
|
740
|
+
line += renderedTiles[col][0][charRow];
|
|
741
|
+
}
|
|
742
|
+
lines.push(line);
|
|
743
|
+
}
|
|
744
|
+
// Center the retreat scene
|
|
745
|
+
const retreatWidth = 3 * TILE_SIZE; // 48 chars
|
|
746
|
+
const retreatHeight = lines.length;
|
|
747
|
+
const retreatOffsetX = Math.max(1, Math.floor((width - retreatWidth) / 2));
|
|
748
|
+
const retreatOffsetY = Math.max(1, Math.floor((height - retreatHeight - 6) / 2));
|
|
749
|
+
// Draw retreat scene tiles
|
|
750
|
+
for (let i = 0; i < lines.length; i++) {
|
|
751
|
+
term.moveTo(retreatOffsetX, retreatOffsetY + i);
|
|
752
|
+
process.stdout.write(lines[i] + RESET);
|
|
753
|
+
}
|
|
754
|
+
// Retreat messages
|
|
755
|
+
const msgY = retreatOffsetY + retreatHeight + 2;
|
|
756
|
+
const msg1 = `${COLOR_RETREAT}${BOLD}You turned back.${RESET}`;
|
|
757
|
+
const msg2 = `${COLOR_RETREAT}Probably a wise decision.${RESET}`;
|
|
758
|
+
const msg3 = `${DIM}Press y to restart, or q to quit${RESET}`;
|
|
759
|
+
term.moveTo(Math.max(1, Math.floor((width - 16) / 2)), msgY);
|
|
760
|
+
process.stdout.write(msg1);
|
|
761
|
+
term.moveTo(Math.max(1, Math.floor((width - 26) / 2)), msgY + 1);
|
|
762
|
+
process.stdout.write(msg2);
|
|
763
|
+
term.moveTo(Math.max(1, Math.floor((width - 32) / 2)), msgY + 3);
|
|
764
|
+
process.stdout.write(msg3);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Cleanup and restore terminal
|
|
768
|
+
*/
|
|
769
|
+
function cleanup() {
|
|
770
|
+
term.removeAllListeners('key');
|
|
771
|
+
term.grabInput(false);
|
|
772
|
+
term.fullscreen(false);
|
|
773
|
+
term.hideCursor(false);
|
|
774
|
+
}
|
|
775
|
+
// Initial draw
|
|
776
|
+
term.clear();
|
|
777
|
+
drawScene();
|
|
778
|
+
// Handle keyboard input
|
|
779
|
+
term.on('key', (key) => {
|
|
780
|
+
// Quit handling
|
|
781
|
+
if (key === 'q' || key === 'CTRL_C' || key === 'CTRL_Z') {
|
|
782
|
+
cleanup();
|
|
783
|
+
process.exit(0);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
// Death screen - only 'y' to retry
|
|
787
|
+
if (state.phase === 'dead') {
|
|
788
|
+
if (key === 'y' || key === 'Y') {
|
|
789
|
+
cleanup();
|
|
790
|
+
resolve('death');
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
// Retreat screen - 'y' to restart (same as death)
|
|
795
|
+
if (state.phase === 'retreat') {
|
|
796
|
+
if (key === 'y' || key === 'Y') {
|
|
797
|
+
cleanup();
|
|
798
|
+
resolve('death'); // Returning 'death' triggers restart
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// Walking phase - handle arrow key movement
|
|
803
|
+
if (state.phase === 'walking') {
|
|
804
|
+
let newX = state.playerX;
|
|
805
|
+
let newY = state.playerY;
|
|
806
|
+
if (key === 'UP' || key === 'k')
|
|
807
|
+
newY--;
|
|
808
|
+
if (key === 'DOWN' || key === 'j')
|
|
809
|
+
newY++;
|
|
810
|
+
if (key === 'LEFT' || key === 'h')
|
|
811
|
+
newX--;
|
|
812
|
+
if (key === 'RIGHT' || key === 'l')
|
|
813
|
+
newX++;
|
|
814
|
+
// No movement key pressed
|
|
815
|
+
if (newX === state.playerX && newY === state.playerY) {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
// Check if trying to move off screen
|
|
819
|
+
if (isOffScreen(newX, newY)) {
|
|
820
|
+
// Check if this is the valid exit: moving right off screen on path row after seeing sign
|
|
821
|
+
if (newX >= SCENE_WIDTH_TILES && newY === START_Y && state.hasSeenSign) {
|
|
822
|
+
// Successfully exited by walking off the right edge on the path!
|
|
823
|
+
playSfx('quickNotice');
|
|
824
|
+
cleanup();
|
|
825
|
+
resolve('success');
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
// Check if retreating off the left edge on the path
|
|
829
|
+
if (newX < 0 && newY === START_Y) {
|
|
830
|
+
// Turned back - retreat screen
|
|
831
|
+
state.phase = 'retreat';
|
|
832
|
+
playSfx('quickNotice');
|
|
833
|
+
drawRetreatScreen();
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
// Wandered off in wrong direction or skipped sign - death!
|
|
837
|
+
state.phase = 'dead';
|
|
838
|
+
playSfx('death');
|
|
839
|
+
drawDeathScreen();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
// Check collision with blocked tiles
|
|
843
|
+
if (!isWalkable(newX, newY)) {
|
|
844
|
+
// Can't walk there, don't move
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
// Valid move - update position
|
|
848
|
+
state.playerX = newX;
|
|
849
|
+
state.playerY = newY;
|
|
850
|
+
playSfx('footstep');
|
|
851
|
+
drawScene();
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
export default showForestIntro;
|