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,266 @@
|
|
|
1
|
+
// TUI rendering logic
|
|
2
|
+
// Handles updating the display with agent outputs and system status
|
|
3
|
+
import { toRoman } from '../state.js';
|
|
4
|
+
/**
|
|
5
|
+
* Box drawing characters for borders
|
|
6
|
+
*/
|
|
7
|
+
const BOX_CHARS = {
|
|
8
|
+
topLeft: '\u2554', // ╔
|
|
9
|
+
topRight: '\u2557', // ╗
|
|
10
|
+
bottomLeft: '\u255A', // ╚
|
|
11
|
+
bottomRight: '\u255D', // ╝
|
|
12
|
+
horizontal: '\u2550', // ═
|
|
13
|
+
vertical: '\u2551', // ║
|
|
14
|
+
leftT: '\u2560', // ╠
|
|
15
|
+
rightT: '\u2563', // ╣
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Progress bar characters
|
|
19
|
+
*/
|
|
20
|
+
const PROGRESS_CHARS = {
|
|
21
|
+
filled: '\u2588', // █
|
|
22
|
+
empty: '\u2591', // ░
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Tool indicator character
|
|
26
|
+
*/
|
|
27
|
+
const TOOL_INDICATOR = '\u25C8'; // ◈
|
|
28
|
+
/**
|
|
29
|
+
* Animation state for loading dots
|
|
30
|
+
*/
|
|
31
|
+
let animationFrame = 0;
|
|
32
|
+
/**
|
|
33
|
+
* Generates animated dots text based on current animation frame
|
|
34
|
+
* Cycles through: "Working." -> "Working.." -> "Working..."
|
|
35
|
+
* @param baseText - The base text to animate (e.g., "Working" or "Waiting for Arbiter")
|
|
36
|
+
* @returns The text with animated dots appended
|
|
37
|
+
*/
|
|
38
|
+
export function getAnimatedDots(baseText) {
|
|
39
|
+
const dotCount = (animationFrame % 3) + 1;
|
|
40
|
+
return baseText + '.'.repeat(dotCount);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Advances the animation frame for the loading dots
|
|
44
|
+
* Should be called by an interval timer
|
|
45
|
+
*/
|
|
46
|
+
export function advanceAnimation() {
|
|
47
|
+
animationFrame = (animationFrame + 1) % 3;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resets the animation frame to 0
|
|
51
|
+
*/
|
|
52
|
+
export function resetAnimation() {
|
|
53
|
+
animationFrame = 0;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Renders the conversation log to the conversation box
|
|
57
|
+
* Formats messages with speakers (You:, Arbiter:, Orchestrator I:, etc.)
|
|
58
|
+
*/
|
|
59
|
+
export function renderConversation(elements, state) {
|
|
60
|
+
const { conversationBox, screen } = elements;
|
|
61
|
+
const lines = [];
|
|
62
|
+
// Get effective width for text wrapping
|
|
63
|
+
const effectiveWidth = Math.max(screen.width - 6, 74);
|
|
64
|
+
for (const message of state.conversationLog) {
|
|
65
|
+
// Format speaker label
|
|
66
|
+
const speakerLabel = formatSpeakerLabel(message.speaker);
|
|
67
|
+
// Format and wrap the message text
|
|
68
|
+
const wrappedText = wrapText(message.text, effectiveWidth - speakerLabel.length - 2);
|
|
69
|
+
const textLines = wrappedText.split('\n');
|
|
70
|
+
// First line with speaker label
|
|
71
|
+
lines.push(` ${speakerLabel} ${textLines[0]}`);
|
|
72
|
+
// Subsequent lines indented to align with first line
|
|
73
|
+
const indent = ' '.repeat(speakerLabel.length + 3);
|
|
74
|
+
for (let i = 1; i < textLines.length; i++) {
|
|
75
|
+
lines.push(`${indent}${textLines[i]}`);
|
|
76
|
+
}
|
|
77
|
+
// Add empty line between messages
|
|
78
|
+
lines.push('');
|
|
79
|
+
}
|
|
80
|
+
// Join lines and add vertical borders
|
|
81
|
+
const formattedContent = lines
|
|
82
|
+
.map((line) => {
|
|
83
|
+
const paddedLine = line.padEnd(effectiveWidth, ' ');
|
|
84
|
+
return `${BOX_CHARS.vertical}${paddedLine}${BOX_CHARS.vertical}`;
|
|
85
|
+
})
|
|
86
|
+
.join('\n');
|
|
87
|
+
conversationBox.setContent(formattedContent);
|
|
88
|
+
// Auto-scroll to bottom
|
|
89
|
+
conversationBox.setScrollPerc(100);
|
|
90
|
+
screen.render();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Renders the status bar with context percentages and current tool
|
|
94
|
+
*
|
|
95
|
+
* When orchestrator is active:
|
|
96
|
+
* ║ Arbiter ─────────────────────────────────────────────────── ██░░░░░░░░ 18% ║
|
|
97
|
+
* ║ Orchestrator I ──────────────────────────────────────────── ████████░░ 74% ║
|
|
98
|
+
* ║ ◈ Edit (12) ║
|
|
99
|
+
*
|
|
100
|
+
* When no orchestrator (Arbiter speaks to human):
|
|
101
|
+
* ║ Arbiter ─────────────────────────────────────────────────── ██░░░░░░░░ 18% ║
|
|
102
|
+
* ║ Awaiting your command. ║
|
|
103
|
+
*
|
|
104
|
+
* @param waitingState - Optional waiting state to show animated dots
|
|
105
|
+
*/
|
|
106
|
+
export function renderStatus(elements, state, waitingState = 'none') {
|
|
107
|
+
const { statusBox, screen } = elements;
|
|
108
|
+
const effectiveWidth = Math.max(screen.width - 2, 78);
|
|
109
|
+
// Status separator at top
|
|
110
|
+
const separator = BOX_CHARS.leftT + BOX_CHARS.horizontal.repeat(effectiveWidth) + BOX_CHARS.rightT;
|
|
111
|
+
// Progress bar width (10 characters for the bar)
|
|
112
|
+
const barWidth = 10;
|
|
113
|
+
// Build Arbiter status line
|
|
114
|
+
const arbiterLabel = 'Arbiter';
|
|
115
|
+
const arbiterBar = renderProgressBar(state.arbiterContextPercent, barWidth);
|
|
116
|
+
const arbiterPercent = `${Math.round(state.arbiterContextPercent)}%`.padStart(4);
|
|
117
|
+
const arbiterDashes = createDashLine(effectiveWidth - arbiterLabel.length - barWidth - arbiterPercent.length - 8);
|
|
118
|
+
const arbiterLine = `${BOX_CHARS.vertical} ${arbiterLabel} ${arbiterDashes} ${arbiterBar} ${arbiterPercent} ${BOX_CHARS.vertical}`;
|
|
119
|
+
let orchestratorLine;
|
|
120
|
+
let toolLine;
|
|
121
|
+
if (state.currentOrchestrator) {
|
|
122
|
+
// Orchestrator status line
|
|
123
|
+
const orchLabel = `Orchestrator ${toRoman(state.currentOrchestrator.number)}`;
|
|
124
|
+
const orchBar = renderProgressBar(state.currentOrchestrator.contextPercent, barWidth);
|
|
125
|
+
const orchPercent = `${Math.round(state.currentOrchestrator.contextPercent)}%`.padStart(4);
|
|
126
|
+
const orchDashes = createDashLine(effectiveWidth - orchLabel.length - barWidth - orchPercent.length - 8);
|
|
127
|
+
orchestratorLine = `${BOX_CHARS.vertical} ${orchLabel} ${orchDashes} ${orchBar} ${orchPercent} ${BOX_CHARS.vertical}`;
|
|
128
|
+
// Tool indicator line
|
|
129
|
+
if (state.currentOrchestrator.currentTool) {
|
|
130
|
+
const toolText = `${TOOL_INDICATOR} ${state.currentOrchestrator.currentTool} (${state.currentOrchestrator.toolCallCount})`;
|
|
131
|
+
const toolPadding = ' '.repeat(effectiveWidth - toolText.length - 2);
|
|
132
|
+
toolLine = `${BOX_CHARS.vertical} ${toolText}${toolPadding}${BOX_CHARS.vertical}`;
|
|
133
|
+
}
|
|
134
|
+
else if (waitingState === 'orchestrator') {
|
|
135
|
+
// Show animated dots when waiting for orchestrator response
|
|
136
|
+
const waitingText = getAnimatedDots('Working');
|
|
137
|
+
const waitingPadding = ' '.repeat(effectiveWidth - waitingText.length - 2);
|
|
138
|
+
toolLine = `${BOX_CHARS.vertical} ${waitingText}${waitingPadding}${BOX_CHARS.vertical}`;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const waitingText = 'Working...';
|
|
142
|
+
const waitingPadding = ' '.repeat(effectiveWidth - waitingText.length - 2);
|
|
143
|
+
toolLine = `${BOX_CHARS.vertical} ${waitingText}${waitingPadding}${BOX_CHARS.vertical}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (waitingState === 'arbiter') {
|
|
147
|
+
// Waiting for Arbiter response - show animated dots
|
|
148
|
+
const waitingText = getAnimatedDots('Waiting for Arbiter');
|
|
149
|
+
const waitingPadding = ' '.repeat(effectiveWidth - waitingText.length - 2);
|
|
150
|
+
orchestratorLine = `${BOX_CHARS.vertical} ${waitingText}${waitingPadding}${BOX_CHARS.vertical}`;
|
|
151
|
+
toolLine = `${BOX_CHARS.vertical}${' '.repeat(effectiveWidth)}${BOX_CHARS.vertical}`;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// No orchestrator - show awaiting message
|
|
155
|
+
const awaitingText = 'Awaiting your command.';
|
|
156
|
+
const awaitingPadding = ' '.repeat(effectiveWidth - awaitingText.length - 2);
|
|
157
|
+
orchestratorLine = `${BOX_CHARS.vertical} ${awaitingText}${awaitingPadding}${BOX_CHARS.vertical}`;
|
|
158
|
+
toolLine = `${BOX_CHARS.vertical}${' '.repeat(effectiveWidth)}${BOX_CHARS.vertical}`;
|
|
159
|
+
}
|
|
160
|
+
statusBox.setContent(`${separator}\n${arbiterLine}\n${orchestratorLine}\n${toolLine}`);
|
|
161
|
+
screen.render();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Creates an ASCII progress bar
|
|
165
|
+
* @param percent - Current percentage (0-100)
|
|
166
|
+
* @param width - Total width of the progress bar
|
|
167
|
+
* @returns Progress bar string like "████████░░"
|
|
168
|
+
*/
|
|
169
|
+
export function renderProgressBar(percent, width) {
|
|
170
|
+
// Clamp percent between 0 and 100
|
|
171
|
+
const clampedPercent = Math.max(0, Math.min(100, percent));
|
|
172
|
+
// Calculate filled width
|
|
173
|
+
const filledWidth = Math.round((clampedPercent / 100) * width);
|
|
174
|
+
const emptyWidth = width - filledWidth;
|
|
175
|
+
// Build progress bar
|
|
176
|
+
return PROGRESS_CHARS.filled.repeat(filledWidth) + PROGRESS_CHARS.empty.repeat(emptyWidth);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Formats a speaker label with appropriate styling
|
|
180
|
+
*/
|
|
181
|
+
function formatSpeakerLabel(speaker) {
|
|
182
|
+
switch (speaker) {
|
|
183
|
+
case 'human':
|
|
184
|
+
return '{bold}You:{/bold}';
|
|
185
|
+
case 'arbiter':
|
|
186
|
+
return '{bold}{yellow-fg}Arbiter:{/yellow-fg}{/bold}';
|
|
187
|
+
default:
|
|
188
|
+
// Orchestrator labels come through as-is (e.g., "Orchestrator I")
|
|
189
|
+
if (speaker.startsWith('Orchestrator')) {
|
|
190
|
+
return `{bold}{cyan-fg}${speaker}:{/cyan-fg}{/bold}`;
|
|
191
|
+
}
|
|
192
|
+
return `{bold}${speaker}:{/bold}`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Wraps text to fit within a specified width
|
|
197
|
+
*/
|
|
198
|
+
function wrapText(text, maxWidth) {
|
|
199
|
+
if (maxWidth <= 0) {
|
|
200
|
+
return text;
|
|
201
|
+
}
|
|
202
|
+
const words = text.split(' ');
|
|
203
|
+
const lines = [];
|
|
204
|
+
let currentLine = '';
|
|
205
|
+
for (const word of words) {
|
|
206
|
+
// Handle words that are longer than maxWidth
|
|
207
|
+
if (word.length > maxWidth) {
|
|
208
|
+
// If there's content in the current line, push it first
|
|
209
|
+
if (currentLine) {
|
|
210
|
+
lines.push(currentLine.trim());
|
|
211
|
+
currentLine = '';
|
|
212
|
+
}
|
|
213
|
+
// Break the long word
|
|
214
|
+
let remaining = word;
|
|
215
|
+
while (remaining.length > maxWidth) {
|
|
216
|
+
lines.push(remaining.substring(0, maxWidth));
|
|
217
|
+
remaining = remaining.substring(maxWidth);
|
|
218
|
+
}
|
|
219
|
+
currentLine = `${remaining} `;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// Check if adding this word would exceed the limit
|
|
223
|
+
if (currentLine.length + word.length + 1 > maxWidth) {
|
|
224
|
+
lines.push(currentLine.trim());
|
|
225
|
+
currentLine = `${word} `;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
currentLine += `${word} `;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Add remaining content
|
|
232
|
+
if (currentLine.trim()) {
|
|
233
|
+
lines.push(currentLine.trim());
|
|
234
|
+
}
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Creates a dash line for status bar alignment
|
|
239
|
+
* Uses em-dash (─) for cleaner appearance
|
|
240
|
+
*/
|
|
241
|
+
function createDashLine(length) {
|
|
242
|
+
const dashChar = '\u2500'; // ─
|
|
243
|
+
return dashChar.repeat(Math.max(0, length));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Renders the input area with prompt
|
|
247
|
+
*/
|
|
248
|
+
export function renderInputArea(elements) {
|
|
249
|
+
const { screen } = elements;
|
|
250
|
+
const effectiveWidth = Math.max(screen.width - 2, 78);
|
|
251
|
+
// Input separator and prompt are handled by the layout
|
|
252
|
+
// This function can be used for additional input area styling if needed
|
|
253
|
+
// Create input separator
|
|
254
|
+
const _inputSeparator = BOX_CHARS.leftT + BOX_CHARS.horizontal.repeat(effectiveWidth) + BOX_CHARS.rightT;
|
|
255
|
+
// The inputBox already has its own styling from layout
|
|
256
|
+
// We can prepend a separator to the status box if needed
|
|
257
|
+
screen.render();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Updates the entire display
|
|
261
|
+
*/
|
|
262
|
+
export function renderAll(elements, state) {
|
|
263
|
+
renderConversation(elements, state);
|
|
264
|
+
renderStatus(elements, state);
|
|
265
|
+
elements.screen.render();
|
|
266
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene state and composition module for the Arbiter TUI
|
|
3
|
+
*
|
|
4
|
+
* Manages scene state and creates tile grids for rendering the wizard council scene.
|
|
5
|
+
* The scene shows a human, the arbiter, a spellbook, a campfire, and demons.
|
|
6
|
+
*/
|
|
7
|
+
import { type Tileset } from './tileset.js';
|
|
8
|
+
/**
|
|
9
|
+
* State for a single hop animation at a position
|
|
10
|
+
* Each hop = 500ms (250ms up + 250ms down)
|
|
11
|
+
*/
|
|
12
|
+
export interface HopState {
|
|
13
|
+
remaining: number;
|
|
14
|
+
frameInHop: 0 | 1;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Scene state describing positions and counts of all scene elements
|
|
18
|
+
*/
|
|
19
|
+
export interface SceneState {
|
|
20
|
+
arbiterPos: -1 | 0 | 1 | 2;
|
|
21
|
+
demonCount: number;
|
|
22
|
+
focusTarget: 'human' | 'arbiter' | 'demon' | null;
|
|
23
|
+
selectedCharacter: number;
|
|
24
|
+
humanCol: number;
|
|
25
|
+
activeHops: Map<string, HopState>;
|
|
26
|
+
bubbleVisible: boolean;
|
|
27
|
+
showSpellbook: boolean;
|
|
28
|
+
chatBubbleTarget: 'human' | 'arbiter' | 'conjuring' | null;
|
|
29
|
+
alertTarget: 'human' | 'arbiter' | 'conjuring' | null;
|
|
30
|
+
scrollVisible: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Tile specification - either a simple tile index or an object with mirroring
|
|
34
|
+
*/
|
|
35
|
+
export type TileSpec = number | {
|
|
36
|
+
tile: number;
|
|
37
|
+
mirrored: boolean;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Create initial scene state with defaults
|
|
41
|
+
*/
|
|
42
|
+
export declare function createInitialSceneState(): SceneState;
|
|
43
|
+
/**
|
|
44
|
+
* Create a 7x6 grid of tile specifications based on scene state
|
|
45
|
+
*
|
|
46
|
+
* Scene Layout:
|
|
47
|
+
* - Row 0-1: Trees on edges (col 0 and 6), grass elsewhere
|
|
48
|
+
* - Row 2: Human at col 1, Arbiter at col 2-4 (based on arbiterPos), Cauldron at col 5, tree at col 6
|
|
49
|
+
* - Row 3: Tree at col 0, grass, campfire at col 5, tree at col 6
|
|
50
|
+
* - Row 4-5: Trees at col 0 and 6, grass elsewhere
|
|
51
|
+
* - Demons spawn around campfire at col 5-6, rows 1-3
|
|
52
|
+
* - Smoke bubbles appear above cauldron when working
|
|
53
|
+
* - Spellbook appears to the left of arbiter when at position 2
|
|
54
|
+
*/
|
|
55
|
+
export declare function createScene(state: SceneState): TileSpec[][];
|
|
56
|
+
/**
|
|
57
|
+
* Render the scene to an ANSI string
|
|
58
|
+
*
|
|
59
|
+
* @param tileset - The loaded tileset
|
|
60
|
+
* @param scene - The tile grid from createScene
|
|
61
|
+
* @param activeHops - Map of "row,col" -> HopState for positions that should hop
|
|
62
|
+
* @param sceneState - Optional scene state for chat bubble rendering
|
|
63
|
+
*/
|
|
64
|
+
export declare function renderScene(tileset: Tileset, scene: TileSpec[][], activeHops?: Map<string, HopState>, sceneState?: SceneState): string;
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene state and composition module for the Arbiter TUI
|
|
3
|
+
*
|
|
4
|
+
* Manages scene state and creates tile grids for rendering the wizard council scene.
|
|
5
|
+
* The scene shows a human, the arbiter, a spellbook, a campfire, and demons.
|
|
6
|
+
*/
|
|
7
|
+
import { CHAR_HEIGHT, compositeQuarterTile, compositeTiles, extractQuarterTile, extractTile, mirrorTile, renderTile, TILE, } from './tileset.js';
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Constants
|
|
10
|
+
// ============================================================================
|
|
11
|
+
const SCENE_WIDTH = 7;
|
|
12
|
+
const SCENE_HEIGHT = 6;
|
|
13
|
+
// Demon spawn positions around the campfire (order matters for spawning)
|
|
14
|
+
const DEMON_POSITIONS = [
|
|
15
|
+
{ row: 2, col: 6, tile: TILE.DEMON_1 }, // right of fire (first)
|
|
16
|
+
{ row: 1, col: 6, tile: TILE.DEMON_2 }, // above-right
|
|
17
|
+
{ row: 3, col: 6, tile: TILE.DEMON_3 }, // below-right
|
|
18
|
+
{ row: 1, col: 5, tile: TILE.DEMON_4 }, // above fire
|
|
19
|
+
{ row: 3, col: 5, tile: TILE.DEMON_5 }, // below fire
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Get a varied grass tile based on position for visual variety
|
|
23
|
+
* Returns ~30% sparse grass, ~70% regular grass using a deterministic pattern
|
|
24
|
+
*/
|
|
25
|
+
function getGrassTile(row, col) {
|
|
26
|
+
// Use a simple pattern based on position for variety
|
|
27
|
+
// This creates a natural-looking distribution of grass types
|
|
28
|
+
const pattern = (row * 3 + col * 7) % 10;
|
|
29
|
+
if (pattern < 3)
|
|
30
|
+
return TILE.GRASS_SPARSE;
|
|
31
|
+
return TILE.GRASS;
|
|
32
|
+
}
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Tile Render Cache
|
|
35
|
+
// ============================================================================
|
|
36
|
+
const tileCache = new Map();
|
|
37
|
+
/**
|
|
38
|
+
* Generate cache key for a tile render
|
|
39
|
+
*/
|
|
40
|
+
function getCacheKey(tileIndex, mirrored) {
|
|
41
|
+
return `${tileIndex}:${mirrored ? 'M' : 'N'}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get or create a cached tile render
|
|
45
|
+
*/
|
|
46
|
+
function getTileRender(tileset, grassPixels, tileIndex, mirrored = false) {
|
|
47
|
+
const key = getCacheKey(tileIndex, mirrored);
|
|
48
|
+
if (tileCache.has(key)) {
|
|
49
|
+
return tileCache.get(key);
|
|
50
|
+
}
|
|
51
|
+
let pixels = extractTile(tileset, tileIndex);
|
|
52
|
+
// Composite tiles >= 80 on grass (characters, objects, etc.)
|
|
53
|
+
if (tileIndex >= 80) {
|
|
54
|
+
pixels = compositeTiles(pixels, grassPixels, 1);
|
|
55
|
+
}
|
|
56
|
+
if (mirrored) {
|
|
57
|
+
pixels = mirrorTile(pixels);
|
|
58
|
+
}
|
|
59
|
+
const rendered = renderTile(pixels);
|
|
60
|
+
tileCache.set(key, rendered);
|
|
61
|
+
return rendered;
|
|
62
|
+
}
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Factory Function
|
|
65
|
+
// ============================================================================
|
|
66
|
+
/**
|
|
67
|
+
* Create initial scene state with defaults
|
|
68
|
+
*/
|
|
69
|
+
export function createInitialSceneState() {
|
|
70
|
+
return {
|
|
71
|
+
arbiterPos: 2, // Start by fire (row 3), walks to scroll when first message ready
|
|
72
|
+
demonCount: 0,
|
|
73
|
+
focusTarget: null,
|
|
74
|
+
selectedCharacter: TILE.HUMAN_1,
|
|
75
|
+
humanCol: 1, // Default position (after entry animation)
|
|
76
|
+
// Animation state
|
|
77
|
+
activeHops: new Map(),
|
|
78
|
+
bubbleVisible: false,
|
|
79
|
+
showSpellbook: false,
|
|
80
|
+
// Chat bubble
|
|
81
|
+
chatBubbleTarget: null,
|
|
82
|
+
// Alert/exclamation indicator
|
|
83
|
+
alertTarget: null,
|
|
84
|
+
// Scroll of requirements
|
|
85
|
+
scrollVisible: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Scene Creation
|
|
90
|
+
// ============================================================================
|
|
91
|
+
/**
|
|
92
|
+
* Create a 7x6 grid of tile specifications based on scene state
|
|
93
|
+
*
|
|
94
|
+
* Scene Layout:
|
|
95
|
+
* - Row 0-1: Trees on edges (col 0 and 6), grass elsewhere
|
|
96
|
+
* - Row 2: Human at col 1, Arbiter at col 2-4 (based on arbiterPos), Cauldron at col 5, tree at col 6
|
|
97
|
+
* - Row 3: Tree at col 0, grass, campfire at col 5, tree at col 6
|
|
98
|
+
* - Row 4-5: Trees at col 0 and 6, grass elsewhere
|
|
99
|
+
* - Demons spawn around campfire at col 5-6, rows 1-3
|
|
100
|
+
* - Smoke bubbles appear above cauldron when working
|
|
101
|
+
* - Spellbook appears to the left of arbiter when at position 2
|
|
102
|
+
*/
|
|
103
|
+
export function createScene(state) {
|
|
104
|
+
const { arbiterPos, demonCount, selectedCharacter, humanCol, bubbleVisible, showSpellbook, scrollVisible, } = state;
|
|
105
|
+
// arbiterPos -1 means off-screen (not visible)
|
|
106
|
+
const arbiterVisible = arbiterPos >= 0;
|
|
107
|
+
// Position mapping:
|
|
108
|
+
// Pos 2: row 3, col 4 (by fire - starting position)
|
|
109
|
+
// Pos 1: row 2, col 4 (by cauldron)
|
|
110
|
+
// Pos 0: row 2, col 3 (by scroll - final position)
|
|
111
|
+
let arbiterCol = -1;
|
|
112
|
+
let arbiterRow = 2;
|
|
113
|
+
if (arbiterVisible) {
|
|
114
|
+
switch (arbiterPos) {
|
|
115
|
+
case 0:
|
|
116
|
+
arbiterCol = 3;
|
|
117
|
+
arbiterRow = 2;
|
|
118
|
+
break;
|
|
119
|
+
case 1:
|
|
120
|
+
arbiterCol = 4;
|
|
121
|
+
arbiterRow = 2;
|
|
122
|
+
break;
|
|
123
|
+
case 2:
|
|
124
|
+
arbiterCol = 4;
|
|
125
|
+
arbiterRow = 3;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const scene = [];
|
|
130
|
+
for (let row = 0; row < SCENE_HEIGHT; row++) {
|
|
131
|
+
const sceneRow = [];
|
|
132
|
+
for (let col = 0; col < SCENE_WIDTH; col++) {
|
|
133
|
+
let tile = getGrassTile(row, col);
|
|
134
|
+
// Trees on left edge (col 0), except row 2 which is the path entrance
|
|
135
|
+
if (col === 0) {
|
|
136
|
+
tile = row === 2 ? TILE.GRASS_SPARSE : TILE.PINE_TREE;
|
|
137
|
+
}
|
|
138
|
+
// Bare trees at specific positions on col 1
|
|
139
|
+
if (col === 1 && (row === 0 || row === 4)) {
|
|
140
|
+
tile = TILE.BARE_TREE;
|
|
141
|
+
}
|
|
142
|
+
// Trees on right edge (col 6)
|
|
143
|
+
if (col === 6 && (row === 0 || row === 4 || row === 5)) {
|
|
144
|
+
tile = TILE.PINE_TREE;
|
|
145
|
+
}
|
|
146
|
+
// Human character on row 2 at humanCol position
|
|
147
|
+
if (row === 2 && col === humanCol && humanCol >= 0 && humanCol < SCENE_WIDTH) {
|
|
148
|
+
tile = selectedCharacter;
|
|
149
|
+
}
|
|
150
|
+
// Scroll of requirements (row 2, col 2) - between human and arbiter
|
|
151
|
+
if (scrollVisible && row === 2 && col === 2) {
|
|
152
|
+
tile = TILE.SCROLL;
|
|
153
|
+
}
|
|
154
|
+
// Spellbook at row 4, col 4 (one down and left from campfire)
|
|
155
|
+
// Shows when explicitly set (during arbiter's working position)
|
|
156
|
+
if (showSpellbook && row === 4 && col === 4) {
|
|
157
|
+
tile = TILE.SPELLBOOK;
|
|
158
|
+
}
|
|
159
|
+
// Cauldron (row 2, col 5)
|
|
160
|
+
if (row === 2 && col === 5) {
|
|
161
|
+
tile = TILE.CAULDRON;
|
|
162
|
+
}
|
|
163
|
+
// Smoke/bubbles above cauldron when bubbleVisible is true
|
|
164
|
+
if (bubbleVisible && row === 1 && col === 5) {
|
|
165
|
+
tile = TILE.SMOKE;
|
|
166
|
+
}
|
|
167
|
+
// Campfire (row 3, col 5)
|
|
168
|
+
if (row === 3 && col === 5) {
|
|
169
|
+
tile = TILE.CAMPFIRE;
|
|
170
|
+
}
|
|
171
|
+
// Arbiter - only draw if visible (arbiterPos >= 0)
|
|
172
|
+
if (arbiterVisible && row === arbiterRow && col === arbiterCol) {
|
|
173
|
+
tile = TILE.ARBITER;
|
|
174
|
+
}
|
|
175
|
+
// Demons based on count (spawn in order)
|
|
176
|
+
for (let i = 0; i < Math.min(demonCount, DEMON_POSITIONS.length); i++) {
|
|
177
|
+
const dp = DEMON_POSITIONS[i];
|
|
178
|
+
if (row === dp.row && col === dp.col) {
|
|
179
|
+
tile = dp.tile;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
sceneRow.push(tile);
|
|
183
|
+
}
|
|
184
|
+
scene.push(sceneRow);
|
|
185
|
+
}
|
|
186
|
+
return scene;
|
|
187
|
+
}
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Scene Rendering
|
|
190
|
+
// ============================================================================
|
|
191
|
+
/**
|
|
192
|
+
* Get the row,col position for a chat bubble target
|
|
193
|
+
*/
|
|
194
|
+
function getChatBubblePosition(state, target) {
|
|
195
|
+
switch (target) {
|
|
196
|
+
case 'human':
|
|
197
|
+
return { row: 2, col: state.humanCol };
|
|
198
|
+
case 'arbiter': {
|
|
199
|
+
const pos = state.arbiterPos;
|
|
200
|
+
switch (pos) {
|
|
201
|
+
case 0:
|
|
202
|
+
return { row: 2, col: 3 }; // By scroll
|
|
203
|
+
case 1:
|
|
204
|
+
return { row: 2, col: 4 }; // By cauldron
|
|
205
|
+
case 2:
|
|
206
|
+
return { row: 3, col: 4 }; // By fire
|
|
207
|
+
default:
|
|
208
|
+
return { row: 2, col: 3 };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
case 'conjuring':
|
|
212
|
+
// First demon position
|
|
213
|
+
return { row: 2, col: 6 };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Cache for the chat bubble quarter tile (extracted once, reused)
|
|
217
|
+
let chatBubbleQuarterCache = null;
|
|
218
|
+
/**
|
|
219
|
+
* Get or create the cached chat bubble quarter tile
|
|
220
|
+
*/
|
|
221
|
+
function getChatBubbleQuarter(tileset) {
|
|
222
|
+
if (!chatBubbleQuarterCache) {
|
|
223
|
+
const quartersTile = extractTile(tileset, TILE.CHAT_BUBBLE_QUARTERS);
|
|
224
|
+
chatBubbleQuarterCache = extractQuarterTile(quartersTile, 'top-right');
|
|
225
|
+
}
|
|
226
|
+
return chatBubbleQuarterCache;
|
|
227
|
+
}
|
|
228
|
+
// Cache for the alert/exclamation quarter tile (extracted once, reused)
|
|
229
|
+
let alertQuarterCache = null;
|
|
230
|
+
/**
|
|
231
|
+
* Get or create the cached alert/exclamation quarter tile
|
|
232
|
+
*/
|
|
233
|
+
function getAlertQuarter(tileset) {
|
|
234
|
+
if (!alertQuarterCache) {
|
|
235
|
+
const quartersTile = extractTile(tileset, TILE.ALERT_QUARTERS);
|
|
236
|
+
alertQuarterCache = extractQuarterTile(quartersTile, 'top-left');
|
|
237
|
+
}
|
|
238
|
+
return alertQuarterCache;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Render the scene to an ANSI string
|
|
242
|
+
*
|
|
243
|
+
* @param tileset - The loaded tileset
|
|
244
|
+
* @param scene - The tile grid from createScene
|
|
245
|
+
* @param activeHops - Map of "row,col" -> HopState for positions that should hop
|
|
246
|
+
* @param sceneState - Optional scene state for chat bubble rendering
|
|
247
|
+
*/
|
|
248
|
+
export function renderScene(tileset, scene, activeHops = new Map(), sceneState) {
|
|
249
|
+
// Get grass pixels for compositing
|
|
250
|
+
const grassPixels = extractTile(tileset, TILE.GRASS);
|
|
251
|
+
// Determine chat bubble position if target is set
|
|
252
|
+
let bubbleRow = -1;
|
|
253
|
+
let bubbleCol = -1;
|
|
254
|
+
if (sceneState?.chatBubbleTarget) {
|
|
255
|
+
const pos = getChatBubblePosition(sceneState, sceneState.chatBubbleTarget);
|
|
256
|
+
bubbleRow = pos.row;
|
|
257
|
+
bubbleCol = pos.col;
|
|
258
|
+
}
|
|
259
|
+
// Determine alert/exclamation position if target is set
|
|
260
|
+
let alertRow = -1;
|
|
261
|
+
let alertCol = -1;
|
|
262
|
+
if (sceneState?.alertTarget) {
|
|
263
|
+
const pos = getChatBubblePosition(sceneState, sceneState.alertTarget);
|
|
264
|
+
alertRow = pos.row;
|
|
265
|
+
alertCol = pos.col;
|
|
266
|
+
}
|
|
267
|
+
const renderedTiles = [];
|
|
268
|
+
for (let row = 0; row < scene.length; row++) {
|
|
269
|
+
const renderedRow = [];
|
|
270
|
+
for (let col = 0; col < scene[row].length; col++) {
|
|
271
|
+
const tileSpec = scene[row][col];
|
|
272
|
+
let tileIndex;
|
|
273
|
+
let mirrored = false;
|
|
274
|
+
if (typeof tileSpec === 'number') {
|
|
275
|
+
tileIndex = tileSpec;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
tileIndex = tileSpec.tile;
|
|
279
|
+
mirrored = tileSpec.mirrored;
|
|
280
|
+
}
|
|
281
|
+
// Check if this position needs an overlay (chat bubble or alert)
|
|
282
|
+
const needsBubble = row === bubbleRow && col === bubbleCol;
|
|
283
|
+
const needsAlert = row === alertRow && col === alertCol;
|
|
284
|
+
if (needsBubble || needsAlert) {
|
|
285
|
+
// Render without cache, adding the overlay
|
|
286
|
+
let pixels = extractTile(tileset, tileIndex);
|
|
287
|
+
// Composite on grass if needed
|
|
288
|
+
if (tileIndex >= 80) {
|
|
289
|
+
pixels = compositeTiles(pixels, grassPixels, 1);
|
|
290
|
+
}
|
|
291
|
+
if (mirrored) {
|
|
292
|
+
pixels = mirrorTile(pixels);
|
|
293
|
+
}
|
|
294
|
+
// Add chat bubble to top-right corner
|
|
295
|
+
if (needsBubble) {
|
|
296
|
+
const bubbleQuarter = getChatBubbleQuarter(tileset);
|
|
297
|
+
pixels = compositeQuarterTile(pixels, bubbleQuarter, 'top-right', 1);
|
|
298
|
+
}
|
|
299
|
+
// Add alert/exclamation to top-left corner
|
|
300
|
+
if (needsAlert) {
|
|
301
|
+
const alertQuarter = getAlertQuarter(tileset);
|
|
302
|
+
pixels = compositeQuarterTile(pixels, alertQuarter, 'top-left', 1);
|
|
303
|
+
}
|
|
304
|
+
renderedRow.push(renderTile(pixels));
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
// Use cached render
|
|
308
|
+
renderedRow.push(getTileRender(tileset, grassPixels, tileIndex, mirrored));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
renderedTiles.push(renderedRow);
|
|
312
|
+
}
|
|
313
|
+
// Build output string
|
|
314
|
+
// Hop detection now uses activeHops map - check if position is hopping and in "up" frame
|
|
315
|
+
// When hopping, we need to shift the hopping tile up by 1 row
|
|
316
|
+
// This means: at the row above, we show the bottom row of the hopping tile
|
|
317
|
+
// and at the tile's normal position, we show rows shifted up
|
|
318
|
+
const lines = [];
|
|
319
|
+
for (let tileRow = 0; tileRow < scene.length; tileRow++) {
|
|
320
|
+
for (let charRow = 0; charRow < CHAR_HEIGHT; charRow++) {
|
|
321
|
+
let line = '';
|
|
322
|
+
for (let tileCol = 0; tileCol < scene[tileRow].length; tileCol++) {
|
|
323
|
+
// Check if this position is hopping (and in "up" frame)
|
|
324
|
+
const hopKey = `${tileRow},${tileCol}`;
|
|
325
|
+
const hopState = activeHops.get(hopKey);
|
|
326
|
+
const isHoppingTile = hopState && hopState.frameInHop === 0;
|
|
327
|
+
// Check if tile above is hopping (for the overflow effect)
|
|
328
|
+
const hopKeyBelow = `${tileRow + 1},${tileCol}`;
|
|
329
|
+
const hopStateBelow = activeHops.get(hopKeyBelow);
|
|
330
|
+
const isTileAboveHopping = hopStateBelow && hopStateBelow.frameInHop === 0;
|
|
331
|
+
if (isHoppingTile) {
|
|
332
|
+
// For the hopping tile, show the row below (shifted up)
|
|
333
|
+
// If charRow is 0, we show nothing special (already handled by tile above)
|
|
334
|
+
// Otherwise show charRow - 1 if we're hopping, but we need to handle the overlap
|
|
335
|
+
if (charRow === 0) {
|
|
336
|
+
// First char row of hopping tile shows second row of the tile
|
|
337
|
+
line += renderedTiles[tileRow][tileCol][1];
|
|
338
|
+
}
|
|
339
|
+
else if (charRow === CHAR_HEIGHT - 1) {
|
|
340
|
+
// Last char row shows grass (the tile has moved up)
|
|
341
|
+
line += renderedTiles[tileRow][tileCol][charRow]; // Actually show grass from tile
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
// Show the next row down (shifted up by 1)
|
|
345
|
+
line += renderedTiles[tileRow][tileCol][charRow + 1];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
else if (isTileAboveHopping) {
|
|
349
|
+
// For the tile above the hopping tile, the last row shows the first row of the hopping tile
|
|
350
|
+
if (charRow === CHAR_HEIGHT - 1) {
|
|
351
|
+
line += renderedTiles[tileRow + 1][tileCol][0];
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
line += renderedTiles[tileRow][tileCol][charRow];
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
line += renderedTiles[tileRow][tileCol][charRow];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
lines.push(line);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Join with newlines (no trailing newline to prevent extra line causing flicker)
|
|
365
|
+
return lines.join('\n');
|
|
366
|
+
}
|