clibuddy 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,6 @@ export declare function changeQuestChain(menu: AdventureMenuState, delta: number
19
19
  export declare function changeExpeditionBiome(menu: AdventureMenuState, delta: number): AdventureMenuState;
20
20
  export declare function getSelectedBiomeId(menu: AdventureMenuState): string;
21
21
  export declare function renderAdventureMenu(menu: AdventureMenuState, state: BuddyState): string[];
22
- export declare function renderRoom(advState: AdventureState, adventure: AdventureDef, buddyName: string): string;
23
- export declare function renderResult(advState: AdventureState, adventure: AdventureDef, buddyName: string): string;
22
+ export declare function renderRoom(advState: AdventureState, adventure: AdventureDef, state: BuddyState): string;
23
+ export declare function renderResult(advState: AdventureState, adventure: AdventureDef, state: BuddyState): string;
24
24
  //# sourceMappingURL=adventureUI.d.ts.map
@@ -10,11 +10,14 @@ exports.getSelectedBiomeId = getSelectedBiomeId;
10
10
  exports.renderAdventureMenu = renderAdventureMenu;
11
11
  exports.renderRoom = renderRoom;
12
12
  exports.renderResult = renderResult;
13
- const types_1 = require("./types");
13
+ const types_1 = require("../buddy/types");
14
+ const species_1 = require("../buddy/species");
15
+ const types_2 = require("./types");
14
16
  const biomes_1 = require("./generation/biomes");
15
- const types_2 = require("../story/types");
17
+ const types_3 = require("../story/types");
16
18
  const npcs_1 = require("../story/npcs");
17
19
  const listUtils_1 = require("../rendering/listUtils");
20
+ const scene_1 = require("../rendering/scene");
18
21
  const screen_1 = require("../rendering/screen");
19
22
  function createAdventureMenu() {
20
23
  return { active: false, tab: "story", selectedIndex: 0, expeditionDifficulty: 1, expeditionBiomeIndex: 0, questChains: [], selectedChainIndex: 0 };
@@ -72,13 +75,13 @@ const DIFF_LABELS = {
72
75
  };
73
76
  function renderAdventureMenu(menu, state) {
74
77
  const lines = [];
75
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
78
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
76
79
  // Tab bar
77
80
  const storyTab = menu.tab === "story" ? `${screen_1.ansi.bold}${screen_1.ansi.colors.yellow}[Story]${screen_1.ansi.reset}` : `${screen_1.ansi.dim}[Story]${screen_1.ansi.reset}`;
78
81
  const expTab = menu.tab === "expedition" ? `${screen_1.ansi.bold}${screen_1.ansi.colors.cyan}[Expedition]${screen_1.ansi.reset}` : `${screen_1.ansi.dim}[Expedition]${screen_1.ansi.reset}`;
79
82
  const endTab = menu.tab === "endless" ? `${screen_1.ansi.bold}${screen_1.ansi.colors.magenta}[Endless]${screen_1.ansi.reset}` : `${screen_1.ansi.dim}[Endless]${screen_1.ansi.reset}`;
80
83
  lines.push(` ${storyTab} ${expTab} ${endTab} ${screen_1.ansi.dim}Tab${screen_1.ansi.reset}`);
81
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
84
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
82
85
  if (menu.tab === "story") {
83
86
  renderQuestsTab(lines, menu, state);
84
87
  }
@@ -88,7 +91,7 @@ function renderAdventureMenu(menu, state) {
88
91
  else {
89
92
  renderEndlessTab(lines, state);
90
93
  }
91
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
94
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
92
95
  return lines;
93
96
  }
94
97
  function renderQuestsTab(lines, menu, state) {
@@ -109,8 +112,8 @@ function renderQuestsTab(lines, menu, state) {
109
112
  // Chapter list
110
113
  for (let i = 0; i < chain.chapters.length; i++) {
111
114
  const chapter = chain.chapters[i];
112
- const completed = (0, types_2.isChapterComplete)(chapter.id, progress);
113
- const blockReason = (0, types_2.canStartChapter)(chapter, progress, state.level);
115
+ const completed = (0, types_3.isChapterComplete)(chapter.id, progress);
116
+ const blockReason = (0, types_3.canStartChapter)(chapter, progress, state.level);
114
117
  const selected = i === menu.selectedIndex;
115
118
  const pointer = selected ? `${screen_1.ansi.colors.cyan}▸${screen_1.ansi.reset}` : " ";
116
119
  if (completed) {
@@ -188,54 +191,76 @@ function renderEndlessTab(lines, state) {
188
191
  lines.push(` ${screen_1.ansi.dim}Enter to begin your descent${screen_1.ansi.reset}`);
189
192
  }
190
193
  // ─── Adventure Room Rendering ────────────────────────────────
191
- function renderRoom(advState, adventure, buddyName) {
192
- const room = (0, types_1.getRoom)(adventure, advState.currentRoomId);
194
+ function renderRoom(advState, adventure, state) {
195
+ const room = (0, types_2.getRoom)(adventure, advState.currentRoomId);
193
196
  if (!room)
194
197
  return " Room not found!";
195
- const progress = (0, types_1.getRoomProgress)(advState);
198
+ const progress = (0, types_2.getRoomProgress)(advState);
196
199
  const lines = [];
200
+ // ── Header with box-drawn frame ──
197
201
  lines.push("");
198
202
  if (adventure.isScene || adventure.hideRoomCounter) {
199
- lines.push(` ${screen_1.ansi.bold}${adventure.name}${screen_1.ansi.reset}`);
203
+ lines.push(` ${screen_1.ansi.bold}╔══ ${adventure.name} ${"═".repeat(Math.max(1, screen_1.UI_WIDTH - adventure.name.length - 6))}╗${screen_1.ansi.reset}`);
200
204
  }
201
205
  else {
202
- lines.push(` ${screen_1.ansi.bold}${adventure.name}${screen_1.ansi.reset} ${screen_1.ansi.dim}Room ${progress}/${adventure.rooms.length}${screen_1.ansi.reset} ${screen_1.ansi.dim}Morale: ${advState.morale}%${screen_1.ansi.reset}`);
206
+ const roomInfo = `Room ${progress}/${adventure.rooms.length}`;
207
+ const fillLen = Math.max(1, screen_1.UI_WIDTH - adventure.name.length - roomInfo.length - 8);
208
+ lines.push(` ${screen_1.ansi.bold}╔══ ${adventure.name} ${"═".repeat(fillLen)} ${roomInfo} ═╗${screen_1.ansi.reset}`);
209
+ }
210
+ if (!adventure.isScene && !adventure.hideRoomCounter) {
211
+ lines.push(` ${screen_1.ansi.dim}Morale: ${advState.morale}%${screen_1.ansi.reset}`);
203
212
  }
204
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(44)}${screen_1.ansi.reset}`);
205
213
  lines.push("");
206
214
  lines.push(` ${screen_1.ansi.bold}${room.title}${screen_1.ansi.reset}`);
207
215
  lines.push("");
208
- // Show NPC ASCII art if present
216
+ // ── Side-by-side buddy + NPC art (or just buddy art) ──
217
+ const species = (0, species_1.getSpecies)(state.speciesId);
218
+ const buddyColor = species ? types_1.RARITY_COLORS[species.rarity] : "";
219
+ const buddyArtLines = species ? species.animations.idle[0].split("\n") : [];
209
220
  if (room.npcsPresent && room.npcsPresent.length > 0) {
210
- for (const npcId of room.npcsPresent) {
211
- const npc = (0, npcs_1.getNPC)(npcId);
212
- if (npc) {
213
- lines.push(` ${screen_1.ansi.dim}${npc.name} the ${npc.title}${screen_1.ansi.reset}`);
214
- for (const artLine of npc.art) {
215
- lines.push(` ${screen_1.ansi.colors.cyan}${artLine}${screen_1.ansi.reset}`);
216
- }
217
- lines.push("");
221
+ const npc = (0, npcs_1.getNPC)(room.npcsPresent[0]);
222
+ if (npc && species) {
223
+ // Side-by-side: buddy (left) + NPC (right)
224
+ const artLines = (0, scene_1.composeDualArt)(buddyArtLines, npc.art, buddyColor, screen_1.ansi.colors.cyan);
225
+ for (const line of artLines) {
226
+ lines.push(` ${line}`);
218
227
  }
228
+ // Labels below the art pair
229
+ const buddyLabel = `${buddyColor}${state.name}${screen_1.ansi.reset}`;
230
+ const npcLabel = `${screen_1.ansi.colors.cyan}${npc.name}${screen_1.ansi.reset} ${screen_1.ansi.dim}${npc.title}${screen_1.ansi.reset}`;
231
+ lines.push(` ${buddyLabel} ${npcLabel}`);
232
+ lines.push("");
233
+ }
234
+ }
235
+ else if (species) {
236
+ // No NPC — show buddy art centered, dimmed slightly
237
+ for (const artLine of buddyArtLines) {
238
+ lines.push(` ${buddyColor}${artLine}${screen_1.ansi.reset}`);
219
239
  }
240
+ lines.push("");
220
241
  }
221
- // Paged text display — show LINES_PER_PAGE lines per page
242
+ // ── Sliding window text — show only current page ──
222
243
  const LINES_PER_PAGE = 3;
223
244
  const totalPages = Math.ceil(room.text.length / LINES_PER_PAGE);
224
245
  const currentPage = advState.textPage;
225
246
  const textFullyShown = advState.textFullyShown || currentPage >= totalPages - 1;
226
- // Show text up to current page
227
- const linesToShow = Math.min(room.text.length, (currentPage + 1) * LINES_PER_PAGE);
228
- for (let i = 0; i < linesToShow; i++) {
247
+ // Scroll indicator: prior text exists
248
+ if (currentPage > 0) {
249
+ lines.push(` ${screen_1.ansi.dim} ▲${screen_1.ansi.reset}`);
250
+ }
251
+ // Show only current page's lines (sliding window)
252
+ const startLine = currentPage * LINES_PER_PAGE;
253
+ const endLine = Math.min(room.text.length, startLine + LINES_PER_PAGE);
254
+ for (let i = startLine; i < endLine; i++) {
229
255
  lines.push(` ${room.text[i]}`);
230
256
  }
231
257
  lines.push("");
232
258
  if (!textFullyShown) {
233
- // More text to show prompt to continue reading
234
- lines.push(` ${screen_1.ansi.dim}▼ Press Enter to continue reading... (${currentPage + 1}/${totalPages})${screen_1.ansi.reset}`);
259
+ lines.push(` ${screen_1.ansi.dim}▼ Enter to continue... (${currentPage + 1}/${totalPages})${screen_1.ansi.reset}`);
235
260
  }
236
261
  else {
237
- // All text shown — show buddy line + choices
238
- lines.push(` ${buddyName}: "${advState.buddyReaction}"`);
262
+ // All text shown — buddy reaction + choices
263
+ lines.push(` ${buddyColor}${state.name}:${screen_1.ansi.reset} "${advState.buddyReaction}"`);
239
264
  lines.push("");
240
265
  if (room.type === "narrative" && room.choices) {
241
266
  for (let i = 0; i < room.choices.length; i++) {
@@ -243,7 +268,6 @@ function renderRoom(advState, adventure, buddyName) {
243
268
  }
244
269
  }
245
270
  else if (room.type === "event" && room.eventChoices) {
246
- // Check if event was already resolved
247
271
  const alreadyResolved = room.eventChoices.some((c) => c.outcome === advState.buddyReaction);
248
272
  if (alreadyResolved) {
249
273
  lines.push(` ${screen_1.ansi.dim}Press Enter to continue...${screen_1.ansi.reset}`);
@@ -269,26 +293,37 @@ function renderRoom(advState, adventure, buddyName) {
269
293
  return lines.join("\n");
270
294
  }
271
295
  // ─── Result Screen ───────────────────────────────────────────
272
- function renderResult(advState, adventure, buddyName) {
296
+ function renderResult(advState, adventure, state) {
273
297
  const lines = [];
298
+ const w = screen_1.UI_WIDTH;
274
299
  lines.push("");
275
- lines.push(` ${screen_1.ansi.bold}${screen_1.ansi.colors.green}Adventure Complete!${screen_1.ansi.reset}`);
276
- lines.push(` ${screen_1.ansi.dim}${"".repeat(44)}${screen_1.ansi.reset}`);
277
- lines.push("");
278
- lines.push(` ${screen_1.ansi.bold}${adventure.name}${screen_1.ansi.reset} Cleared!`);
279
- lines.push("");
280
- lines.push(` Battles: ${advState.battlesWon} won, ${advState.battlesLost} lost, ${advState.battlesFled} fled`);
300
+ lines.push(` ${screen_1.ansi.colors.green}╔${"═".repeat(w)}╗${screen_1.ansi.reset}`);
301
+ lines.push(` ${screen_1.ansi.colors.green}║${screen_1.ansi.reset} ${screen_1.ansi.bold}★ ADVENTURE COMPLETE ★${screen_1.ansi.reset}${" ".repeat(Math.max(0, w - 24))}${screen_1.ansi.colors.green}║${screen_1.ansi.reset}`);
302
+ lines.push(` ${screen_1.ansi.colors.green}╠${"".repeat(w)}╣${screen_1.ansi.reset}`);
303
+ const padLine = (text, visLen) => {
304
+ return ` ${screen_1.ansi.colors.green}║${screen_1.ansi.reset} ${text}${" ".repeat(Math.max(0, w - visLen - 2))}${screen_1.ansi.colors.green}║${screen_1.ansi.reset}`;
305
+ };
306
+ lines.push(padLine(`${screen_1.ansi.bold}${adventure.name}${screen_1.ansi.reset}`, adventure.name.length));
307
+ lines.push(padLine("", 0));
308
+ const battleText = `Battles: ${advState.battlesWon} won, ${advState.battlesLost} lost, ${advState.battlesFled} fled`;
309
+ lines.push(padLine(battleText, battleText.length));
281
310
  if (advState.lootCollected.length > 0) {
282
311
  const lootNames = advState.lootCollected.map((id) => id.replace(/_/g, " "));
283
- lines.push(` Loot: ${lootNames.join(", ")}`);
312
+ const lootText = `Loot: ${lootNames.join(", ")}`;
313
+ lines.push(padLine(lootText, lootText.length));
284
314
  }
285
- lines.push(` XP earned: ${screen_1.ansi.bold}${advState.xpEarned}${screen_1.ansi.reset}`);
286
- lines.push(` Final morale: ${advState.morale}%`);
315
+ const xpText = `XP earned: ${advState.xpEarned}`;
316
+ lines.push(padLine(`${screen_1.ansi.bold}${xpText}${screen_1.ansi.reset}`, xpText.length));
317
+ lines.push(padLine(`Final morale: ${advState.morale}%`, `Final morale: ${advState.morale}%`.length));
287
318
  if (advState.damageTaken === 0 && advState.battlesWon > 0) {
288
- lines.push(` ${screen_1.ansi.colors.yellow}★ PERFECT — No damage taken!${screen_1.ansi.reset}`);
319
+ const perfText = "★ PERFECT — No damage taken!";
320
+ lines.push(padLine(`${screen_1.ansi.colors.yellow}${perfText}${screen_1.ansi.reset}`, perfText.length));
289
321
  }
322
+ lines.push(` ${screen_1.ansi.colors.green}╚${"═".repeat(w)}╝${screen_1.ansi.reset}`);
290
323
  lines.push("");
291
- lines.push(` ${buddyName}: "${advState.buddyReaction}"`);
324
+ const species = (0, species_1.getSpecies)(state.speciesId);
325
+ const buddyColor = species ? types_1.RARITY_COLORS[species.rarity] : "";
326
+ lines.push(` ${buddyColor}${state.name}:${screen_1.ansi.reset} "${advState.buddyReaction}"`);
292
327
  lines.push("");
293
328
  lines.push(` ${screen_1.ansi.dim}Press Enter to return...${screen_1.ansi.reset}`);
294
329
  lines.push("");
@@ -11,6 +11,7 @@ const lines_1 = require("../../dialogue/lines");
11
11
  const skills_1 = require("./skills");
12
12
  const items_1 = require("../../inventory/items");
13
13
  const screen_1 = require("../../rendering/screen");
14
+ const scene_1 = require("../../rendering/scene");
14
15
  const CONDITION_ICONS = {
15
16
  burn: "🔥", poison: "☠", freeze: "❄", stun: "⚡", charm: "💕",
16
17
  blind: "👁", regen: "💚", shield: "🛡", buff: "↑", debuff: "↓",
@@ -37,41 +38,76 @@ function hpBar(current, max, width = 15) {
37
38
  function renderCombat(combat, state, adventureName) {
38
39
  const species = (0, species_1.getSpecies)(state.speciesId);
39
40
  const lines = [];
40
- lines.push("");
41
- lines.push(` ${screen_1.ansi.bold}${adventureName}${screen_1.ansi.reset} ${screen_1.ansi.dim}Combat${screen_1.ansi.reset}`);
42
- lines.push(` ${screen_1.ansi.dim}${"".repeat(44)}${screen_1.ansi.reset}`);
43
- lines.push("");
44
- // Enemy
41
+ const termWidth = process.stdout.columns || 80;
42
+ const speciesColor = species ? types_1.RARITY_COLORS[species.rarity] : "";
43
+ const playerElemIcon = types_2.ELEMENT_ICONS[combat.playerElement] ?? "";
45
44
  const enemyElemIcon = types_2.ELEMENT_ICONS[combat.enemyElement] ?? "";
46
- lines.push(` ${screen_1.ansi.colors.red}${screen_1.ansi.bold}${combat.enemy.name}${screen_1.ansi.reset} ${enemyElemIcon}`);
47
- lines.push(` HP: ${hpBar(combat.enemyStats.hp, combat.enemyStats.maxHp)} ${screen_1.ansi.dim}ATK:${combat.enemyStats.atk} DEF:${combat.enemyStats.def}${screen_1.ansi.reset}${formatConditions(combat.enemyConditions)}`);
48
- for (const artLine of combat.enemy.art) {
49
- lines.push(` ${screen_1.ansi.colors.red}${artLine}${screen_1.ansi.reset}`);
50
- }
45
+ const buddyArtLines = species ? species.animations.idle[0].split("\n") : [];
46
+ const enemyArtLines = combat.enemy.art;
47
+ // Pre-calculate content width for the frame
48
+ const buddyHPpreview = `HP: ${hpBar(combat.playerStats.hp, combat.playerStats.maxHp)}`;
49
+ const enemyHPpreview = `HP: ${hpBar(combat.enemyStats.hp, combat.enemyStats.maxHp)}`;
50
+ const leftColWidth = (0, screen_1.stripAnsi)(buddyHPpreview).length + 2;
51
+ const contentWidth = leftColWidth + (0, screen_1.stripAnsi)(enemyHPpreview).length;
52
+ // ── Header with box frame matched to content ──
53
+ const frameWidth = Math.max(screen_1.UI_WIDTH, contentWidth + 4); // +4 for " " indent
51
54
  lines.push("");
52
- lines.push(` ${screen_1.ansi.dim} vs${screen_1.ansi.reset}`);
55
+ const battleLabel = "⚔ BATTLE";
56
+ const fillLen = Math.max(1, frameWidth - adventureName.length - battleLabel.length - 8);
57
+ lines.push(` ${screen_1.ansi.bold}╔══ ${adventureName} ${"═".repeat(fillLen)} ${battleLabel} ═╗${screen_1.ansi.reset}`);
53
58
  lines.push("");
54
- // Player buddy
55
- const speciesColor = species ? types_1.RARITY_COLORS[species.rarity] : "";
56
- const playerElemIcon = types_2.ELEMENT_ICONS[combat.playerElement] ?? "";
57
- lines.push(` ${speciesColor}${screen_1.ansi.bold}${state.name}${screen_1.ansi.reset} ${playerElemIcon}`);
58
- lines.push(` HP: ${hpBar(combat.playerStats.hp, combat.playerStats.maxHp)} ${screen_1.ansi.dim}ATK:${combat.playerStats.atk} DEF:${combat.playerStats.def}${screen_1.ansi.reset}${formatConditions(combat.playerConditions)}`);
59
- if (species) {
60
- const frame = species.animations.idle[0].split("\n");
61
- for (const artLine of frame) {
62
- lines.push(` ${speciesColor}${artLine}${screen_1.ansi.reset}`);
59
+ // ── Side-by-side layout (buddy left, enemy right) ──
60
+ if (termWidth >= 60 && species) {
61
+ const leftArtWidth = Math.max(...buddyArtLines.map(l => (0, screen_1.stripAnsi)(l).length));
62
+ // Art gap calculated so enemy art starts at same column as enemy HP
63
+ const artGap = Math.max(4, leftColWidth - leftArtWidth);
64
+ const artLines = (0, scene_1.composeDualArt)(buddyArtLines, enemyArtLines, speciesColor, screen_1.ansi.colors.red, artGap);
65
+ // Name labels above art, aligned to same columns
66
+ const buddyLabel = `${speciesColor}${screen_1.ansi.bold}${state.name}${screen_1.ansi.reset} ${playerElemIcon}`;
67
+ const enemyLabel = `${screen_1.ansi.colors.red}${screen_1.ansi.bold}${combat.enemy.name}${screen_1.ansi.reset} ${enemyElemIcon}`;
68
+ lines.push(` ${(0, screen_1.visPad)(buddyLabel, leftColWidth)}${screen_1.ansi.dim}VS${screen_1.ansi.reset} ${enemyLabel}`);
69
+ lines.push("");
70
+ // Art side by side
71
+ for (const line of artLines) {
72
+ lines.push(` ${line}`);
73
+ }
74
+ lines.push("");
75
+ // HP bars side-by-side — aligned to same columns as art
76
+ const buddyHPstr = `HP: ${hpBar(combat.playerStats.hp, combat.playerStats.maxHp)}`;
77
+ const enemyHPstr = `HP: ${hpBar(combat.enemyStats.hp, combat.enemyStats.maxHp)}`;
78
+ const buddyStats = `${screen_1.ansi.dim}ATK:${combat.playerStats.atk} DEF:${combat.playerStats.def}${screen_1.ansi.reset}${formatConditions(combat.playerConditions)}`;
79
+ const enemyStats = `${screen_1.ansi.dim}ATK:${combat.enemyStats.atk} DEF:${combat.enemyStats.def}${screen_1.ansi.reset}${formatConditions(combat.enemyConditions)}`;
80
+ lines.push(` ${(0, screen_1.visPad)(buddyHPstr, leftColWidth)}${enemyHPstr}`);
81
+ lines.push(` ${(0, screen_1.visPad)(buddyStats, leftColWidth)}${enemyStats}`);
82
+ }
83
+ else {
84
+ // ── Narrow fallback: vertical stack (original layout) ──
85
+ lines.push(` ${screen_1.ansi.colors.red}${screen_1.ansi.bold}${combat.enemy.name}${screen_1.ansi.reset} ${enemyElemIcon}`);
86
+ lines.push(` HP: ${hpBar(combat.enemyStats.hp, combat.enemyStats.maxHp)} ${screen_1.ansi.dim}ATK:${combat.enemyStats.atk} DEF:${combat.enemyStats.def}${screen_1.ansi.reset}${formatConditions(combat.enemyConditions)}`);
87
+ for (const artLine of enemyArtLines) {
88
+ lines.push(` ${screen_1.ansi.colors.red}${artLine}${screen_1.ansi.reset}`);
89
+ }
90
+ lines.push("");
91
+ lines.push(` ${screen_1.ansi.dim} vs${screen_1.ansi.reset}`);
92
+ lines.push("");
93
+ lines.push(` ${speciesColor}${screen_1.ansi.bold}${state.name}${screen_1.ansi.reset} ${playerElemIcon}`);
94
+ lines.push(` HP: ${hpBar(combat.playerStats.hp, combat.playerStats.maxHp)} ${screen_1.ansi.dim}ATK:${combat.playerStats.atk} DEF:${combat.playerStats.def}${screen_1.ansi.reset}${formatConditions(combat.playerConditions)}`);
95
+ if (species) {
96
+ for (const artLine of buddyArtLines) {
97
+ lines.push(` ${speciesColor}${artLine}${screen_1.ansi.reset}`);
98
+ }
63
99
  }
64
100
  }
65
101
  lines.push("");
66
- // Combat log (last 2 messages)
102
+ // ── Combat log (last 2 messages) ──
67
103
  const recentLog = combat.log.slice(-2);
68
104
  for (const msg of recentLog) {
69
105
  lines.push(` ${screen_1.ansi.dim}${msg}${screen_1.ansi.reset}`);
70
106
  }
71
107
  lines.push("");
72
- // Action menu (only during player turn)
108
+ // ── Action menu / result ──
73
109
  if (combat.phase === "player_turn") {
74
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(44)}${screen_1.ansi.reset}`);
110
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
75
111
  lines.push(` [1] Attack [2] Skill [3] Item`);
76
112
  lines.push(` [4] Defend [5] Flee`);
77
113
  lines.push(` ${screen_1.ansi.dim}Energy: ${"◆".repeat(combat.combatEnergy)}${"◇".repeat(combat.maxCombatEnergy - combat.combatEnergy)}${screen_1.ansi.reset}`);
@@ -79,15 +115,15 @@ function renderCombat(combat, state, adventureName) {
79
115
  else if (combat.phase === "victory") {
80
116
  const victoryLines = lines_1.COMBAT_VICTORY_LINES[state.speciesId];
81
117
  const victoryLine = victoryLines ? victoryLines[Math.floor(Math.random() * victoryLines.length)] : "We did it!";
82
- lines.push(` ${screen_1.ansi.colors.green}${screen_1.ansi.bold}Victory!${screen_1.ansi.reset} ${screen_1.ansi.dim}+${combat.enemy.xpReward} XP${screen_1.ansi.reset}`);
83
- lines.push(` ${state.name}: "${victoryLine}"`);
118
+ lines.push(` ${screen_1.ansi.colors.green}${screen_1.ansi.bold}Victory!${screen_1.ansi.reset} ${screen_1.ansi.dim}+${combat.enemy.xpReward} XP${screen_1.ansi.reset}`);
119
+ lines.push(` ${speciesColor}${state.name}:${screen_1.ansi.reset} "${victoryLine}"`);
84
120
  lines.push(` ${screen_1.ansi.dim}Press Enter to continue...${screen_1.ansi.reset}`);
85
121
  }
86
122
  else if (combat.phase === "defeat") {
87
123
  const defeatLines = lines_1.COMBAT_DEFEAT_LINES[state.speciesId];
88
124
  const defeatLine = defeatLines ? defeatLines[Math.floor(Math.random() * defeatLines.length)] : "We'll get them next time...";
89
125
  lines.push(` ${screen_1.ansi.colors.red}${screen_1.ansi.bold}Defeated!${screen_1.ansi.reset}`);
90
- lines.push(` ${state.name}: "${defeatLine}"`);
126
+ lines.push(` ${speciesColor}${state.name}:${screen_1.ansi.reset} "${defeatLine}"`);
91
127
  lines.push(` ${screen_1.ansi.dim}Press Enter to retreat...${screen_1.ansi.reset}`);
92
128
  }
93
129
  else if (combat.phase === "fled") {
@@ -95,7 +131,6 @@ function renderCombat(combat, state, adventureName) {
95
131
  lines.push(` ${screen_1.ansi.dim}Press Enter to continue...${screen_1.ansi.reset}`);
96
132
  }
97
133
  else {
98
- // Animating
99
134
  lines.push(` ${screen_1.ansi.dim}...${screen_1.ansi.reset}`);
100
135
  }
101
136
  lines.push("");
@@ -104,7 +139,7 @@ function renderCombat(combat, state, adventureName) {
104
139
  function renderSkillMenu(combat, state) {
105
140
  const skills = (0, skills_1.getAvailableSkills)(state.speciesId, state.level);
106
141
  const lines = [];
107
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(44)}${screen_1.ansi.reset}`);
142
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
108
143
  lines.push(` ${screen_1.ansi.bold}Skills${screen_1.ansi.reset} ${screen_1.ansi.dim}Energy: ${"◆".repeat(combat.combatEnergy)}${"◇".repeat(combat.maxCombatEnergy - combat.combatEnergy)}${screen_1.ansi.reset}`);
109
144
  for (let i = 0; i < skills.length; i++) {
110
145
  const s = skills[i];
@@ -113,7 +148,7 @@ function renderSkillMenu(combat, state) {
113
148
  lines.push(` ${color}[${i + 1}] ${s.name} (${s.energyCost}◆) — ${s.description}${screen_1.ansi.reset}`);
114
149
  }
115
150
  lines.push(` ${screen_1.ansi.dim}[0] Back${screen_1.ansi.reset}`);
116
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(44)}${screen_1.ansi.reset}`);
151
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
117
152
  return lines.join("\n");
118
153
  }
119
154
  /** Get inventory slots usable in combat (medicine items with health effect) */
@@ -135,7 +170,7 @@ function getCombatItems(inventory) {
135
170
  function renderItemMenu(combat, inventory, selectedIndex) {
136
171
  const items = getCombatItems(inventory);
137
172
  const lines = [];
138
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(44)}${screen_1.ansi.reset}`);
173
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
139
174
  lines.push(` ${screen_1.ansi.bold}Items${screen_1.ansi.reset} ${screen_1.ansi.dim}HP: ${combat.playerStats.hp}/${combat.playerStats.maxHp}${screen_1.ansi.reset}`);
140
175
  if (items.length === 0) {
141
176
  lines.push(` ${screen_1.ansi.dim} No medicine in inventory${screen_1.ansi.reset}`);
@@ -155,7 +190,7 @@ function renderItemMenu(combat, inventory, selectedIndex) {
155
190
  }
156
191
  }
157
192
  }
158
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(44)}${screen_1.ansi.reset}`);
193
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
159
194
  lines.push(` ${screen_1.ansi.dim}Enter to use, Esc to close${screen_1.ansi.reset}`);
160
195
  return lines.join("\n");
161
196
  }
package/dist/index.js CHANGED
@@ -137,10 +137,10 @@ function redraw() {
137
137
  screen.draw(screen_, ` ${screen_1.ansi.dim}Esc to flee${screen_1.ansi.reset}`);
138
138
  }
139
139
  else if (activeAdventure.phase === "result") {
140
- screen.draw((0, adventureUI_1.renderResult)(activeAdventure, adv, buddyState.name), ` ${screen_1.ansi.dim}Press Enter${screen_1.ansi.reset}`);
140
+ screen.draw((0, adventureUI_1.renderResult)(activeAdventure, adv, buddyState), ` ${screen_1.ansi.dim}Press Enter${screen_1.ansi.reset}`);
141
141
  }
142
142
  else {
143
- screen.draw((0, adventureUI_1.renderRoom)(activeAdventure, adv, buddyState.name), ` ${screen_1.ansi.dim}Esc to retreat${screen_1.ansi.reset}`);
143
+ screen.draw((0, adventureUI_1.renderRoom)(activeAdventure, adv, buddyState), ` ${screen_1.ansi.dim}Esc to retreat${screen_1.ansi.reset}`);
144
144
  }
145
145
  }
146
146
  return;
@@ -11,18 +11,26 @@ const items_1 = require("./items");
11
11
  const scene_1 = require("./scene");
12
12
  const screen_1 = require("./screen");
13
13
  const updates_1 = require("../updates");
14
+ /** Per-stat identity colors */
15
+ const STAT_COLORS = {
16
+ Hunger: screen_1.ansi.colors.green,
17
+ Happiness: screen_1.ansi.colors.magenta,
18
+ Health: screen_1.ansi.colors.cyan,
19
+ Energy: screen_1.ansi.colors.blue,
20
+ };
14
21
  function statBar(value, label, width = 20) {
15
22
  const filled = Math.round((value / 100) * width);
16
23
  const empty = width - filled;
24
+ // Use per-stat color when healthy, override to yellow/red when low
17
25
  let color;
18
- if (value > 60)
19
- color = screen_1.ansi.colors.green;
20
- else if (value > 30)
26
+ if (value <= 30)
27
+ color = screen_1.ansi.colors.red;
28
+ else if (value <= 60)
21
29
  color = screen_1.ansi.colors.yellow;
22
30
  else
23
- color = screen_1.ansi.colors.red;
24
- const bar = color + "█".repeat(filled) + screen_1.ansi.dim + "░".repeat(empty) + screen_1.ansi.reset;
25
- return ` ${label.padEnd(12)} ${bar} ${color}${Math.round(value).toString().padStart(3)}%${screen_1.ansi.reset}`;
31
+ color = STAT_COLORS[label] ?? screen_1.ansi.colors.green;
32
+ const bar = color + "█".repeat(filled) + screen_1.ansi.colors.gray + "░".repeat(empty) + screen_1.ansi.reset;
33
+ return `${label.padEnd(12)} ${bar} ${color}${Math.round(value).toString().padStart(3)}%${screen_1.ansi.reset}`;
26
34
  }
27
35
  /**
28
36
  * Renders the full main screen content (buddy + stats + mood).
@@ -38,7 +46,7 @@ function renderMainScreen(state, frame, message, dialogue, dialogueSource) {
38
46
  // Header bar
39
47
  lines.push("");
40
48
  lines.push(` ${screen_1.ansi.bold}CLI Buddy${screen_1.ansi.reset} ${screen_1.ansi.dim}v${updates_1.CURRENT_VERSION}${screen_1.ansi.reset}`);
41
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(40)}${screen_1.ansi.reset}`);
49
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
42
50
  if (!state.alive) {
43
51
  lines.push("");
44
52
  lines.push(` ${screen_1.ansi.bold}${screen_1.ansi.colors.red}${state.name} has passed away...${screen_1.ansi.reset}`);
@@ -89,33 +97,87 @@ function renderMainScreen(state, frame, message, dialogue, dialogueSource) {
89
97
  // Compose scene with item
90
98
  const item = items_1.ACTIVITY_ITEMS[activityType] ?? null;
91
99
  const sceneLines = (0, scene_1.composeScene)(buddyArtLines, item, activityType === "idle");
92
- for (const line of sceneLines) {
93
- lines.push(` ${color}${line}${screen_1.ansi.reset}`);
94
- }
95
- // Activity timer
100
+ // Activity timer line
101
+ let timerLine = "";
96
102
  if (state.activity && state.activity.type !== "idle") {
97
103
  const timerLabel = state.activity.type === "sleeping" ? "Sleeping" :
98
104
  state.activity.type === "eating" ? "Eating" : "Playing";
99
105
  const isRecovery = state.activity.unskippable;
100
106
  const label2 = isRecovery ? "Recovering" : timerLabel;
101
107
  const skipHint = isRecovery ? "" : " (press any key to skip)";
102
- lines.push(` ${screen_1.ansi.dim} ${label2}... ${(0, activities_1.formatTimeRemaining)(state.activity)} remaining${skipHint}${screen_1.ansi.reset}`);
108
+ timerLine = `${screen_1.ansi.dim}${label2}... ${(0, activities_1.formatTimeRemaining)(state.activity)} remaining${skipHint}${screen_1.ansi.reset}`;
103
109
  }
104
- lines.push("");
105
- // Stats always visible, update in real time
106
- lines.push(statBar(state.stats.hunger, "Hunger"));
107
- lines.push(statBar(state.stats.happiness, "Happiness"));
108
- lines.push(statBar(state.stats.health, "Health"));
109
- lines.push(statBar(state.stats.energy, "Energy"));
110
- lines.push("");
111
- // Mood message
112
- // Dialogue overrides mood when present
110
+ // Build stat lines
111
+ const statLines = [
112
+ statBar(state.stats.hunger, "Hunger"),
113
+ statBar(state.stats.happiness, "Happiness"),
114
+ statBar(state.stats.health, "Health"),
115
+ statBar(state.stats.energy, "Energy"),
116
+ ];
117
+ // Build mood/dialogue line
118
+ let moodLine;
113
119
  if (dialogue) {
114
120
  const sourceTag = dialogueSource ? `${screen_1.ansi.dim}[${dialogueSource}]${screen_1.ansi.reset} ` : "";
115
- lines.push(` ${sourceTag}${screen_1.ansi.colors.cyan}"${dialogue}"${screen_1.ansi.reset}`);
121
+ moodLine = `${sourceTag}${screen_1.ansi.colors.cyan}"${dialogue}"${screen_1.ansi.reset}`;
122
+ }
123
+ else {
124
+ moodLine = `${screen_1.ansi.dim}${getMoodMessage(state)}${screen_1.ansi.reset}`;
125
+ }
126
+ // Side-by-side layout: buddy left, stats right (if terminal wide enough)
127
+ const termWidth = process.stdout.columns || 80;
128
+ const minWidthForSideBySide = 65;
129
+ // Trim trailing whitespace from scene lines so the left column is tight
130
+ const trimmedSceneLines = sceneLines.map((l) => l.trimEnd());
131
+ // Measure the actual widest scene line for dynamic left column width
132
+ const leftColumnWidth = Math.max(...trimmedSceneLines.map((l) => (0, screen_1.stripAnsi)(l).length), 12) + 2;
133
+ if (termWidth >= minWidthForSideBySide) {
134
+ // Build left column: buddy art only
135
+ const leftLines = [];
136
+ for (const line of trimmedSceneLines) {
137
+ leftLines.push(`${color}${line}${screen_1.ansi.reset}`);
138
+ }
139
+ // Build right column: stats only
140
+ const rightLines = [];
141
+ // Vertical offset to center stats against art
142
+ const artHeight = leftLines.length;
143
+ const statsHeight = statLines.length;
144
+ const topPad = Math.max(0, Math.floor((artHeight - statsHeight) / 2));
145
+ for (let i = 0; i < topPad; i++)
146
+ rightLines.push("");
147
+ for (const sl of statLines) {
148
+ rightLines.push(sl);
149
+ }
150
+ // Merge columns side by side
151
+ const maxRows = Math.max(leftLines.length, rightLines.length);
152
+ for (let i = 0; i < maxRows; i++) {
153
+ const left = leftLines[i] ?? "";
154
+ const right = rightLines[i] ?? "";
155
+ // Pad left column to fixed width (strip ANSI for length calc)
156
+ const leftVisible = (0, screen_1.stripAnsi)(left);
157
+ const pad = Math.max(0, leftColumnWidth - leftVisible.length);
158
+ lines.push(` ${left}${" ".repeat(pad)}${right}`);
159
+ }
160
+ // Timer and mood below the side-by-side section
161
+ if (timerLine) {
162
+ lines.push(` ${timerLine}`);
163
+ }
164
+ lines.push("");
165
+ lines.push(` ${moodLine}`);
116
166
  }
117
167
  else {
118
- lines.push(` ${screen_1.ansi.dim}${getMoodMessage(state)}${screen_1.ansi.reset}`);
168
+ // Narrow terminal: vertical stack (original layout)
169
+ for (const line of sceneLines) {
170
+ lines.push(` ${color}${line}${screen_1.ansi.reset}`);
171
+ }
172
+ if (timerLine) {
173
+ lines.push(` ${timerLine}`);
174
+ }
175
+ lines.push("");
176
+ for (const sl of statLines) {
177
+ lines.push(` ${sl}`);
178
+ }
179
+ lines.push("");
180
+ lines.push(` ${moodLine}`);
119
181
  }
120
182
  // Action feedback message (fades after a few seconds)
121
183
  if (message) {
@@ -137,7 +199,7 @@ function renderRollScreen(phase, speciesName, speciesArt, rarityColor, rarityLab
137
199
  const lines = [];
138
200
  lines.push("");
139
201
  lines.push(` ${screen_1.ansi.bold}CLI Buddy${screen_1.ansi.reset} ${screen_1.ansi.dim}v${updates_1.CURRENT_VERSION}${screen_1.ansi.reset}`);
140
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(40)}${screen_1.ansi.reset}`);
202
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
141
203
  lines.push("");
142
204
  if (phase === "waiting") {
143
205
  lines.push(` ${screen_1.ansi.dim}Welcome! You don't have a buddy yet.${screen_1.ansi.reset}`);
@@ -40,9 +40,9 @@ function formatTimeAgo(timestamp) {
40
40
  }
41
41
  function renderTitlesOverlay(state, overlay) {
42
42
  const lines = [];
43
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
43
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
44
44
  lines.push(` ${screen_1.ansi.bold}Titles${screen_1.ansi.reset} ${screen_1.ansi.dim}(${state.titles.length}/${titles_1.TITLE_RULES.length} earned) Esc to close${screen_1.ansi.reset}`);
45
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
45
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
46
46
  for (let i = 0; i < titles_1.TITLE_RULES.length; i++) {
47
47
  const rule = titles_1.TITLE_RULES[i];
48
48
  const earned = state.titles.includes(rule.id);
@@ -57,15 +57,15 @@ function renderTitlesOverlay(state, overlay) {
57
57
  lines.push(` ${pointer} ${screen_1.ansi.dim}✗ ??? — ${rule.hint}${screen_1.ansi.reset}`);
58
58
  }
59
59
  }
60
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
60
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
61
61
  lines.push(` ${screen_1.ansi.dim}Enter to equip selected title${screen_1.ansi.reset}`);
62
62
  return lines;
63
63
  }
64
64
  function renderNotificationsOverlay(notifications, overlay) {
65
65
  const lines = [];
66
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
66
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
67
67
  lines.push(` ${screen_1.ansi.bold}Notifications${screen_1.ansi.reset} ${screen_1.ansi.dim}(${notifications.length}) Esc to close${screen_1.ansi.reset}`);
68
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
68
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
69
69
  if (notifications.length === 0) {
70
70
  lines.push(` ${screen_1.ansi.dim} No notifications.${screen_1.ansi.reset}`);
71
71
  }
@@ -81,7 +81,7 @@ function renderNotificationsOverlay(notifications, overlay) {
81
81
  lines.push(` ${screen_1.ansi.dim}${timeAgo}${screen_1.ansi.reset}`);
82
82
  }
83
83
  }
84
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
84
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
85
85
  lines.push(` ${screen_1.ansi.dim}Enter to clear all${screen_1.ansi.reset}`);
86
86
  return lines;
87
87
  }
@@ -93,9 +93,9 @@ function getSettingEntries() {
93
93
  }
94
94
  function renderSettingsOverlay(settings, overlay) {
95
95
  const lines = [];
96
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
96
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
97
97
  lines.push(` ${screen_1.ansi.bold}Settings${screen_1.ansi.reset} ${screen_1.ansi.dim}Enter to toggle, Esc to close${screen_1.ansi.reset}`);
98
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
98
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
99
99
  for (let i = 0; i < SETTING_ENTRIES.length; i++) {
100
100
  const entry = SETTING_ENTRIES[i];
101
101
  const value = settings[entry.key];
@@ -106,7 +106,7 @@ function renderSettingsOverlay(settings, overlay) {
106
106
  : `${screen_1.ansi.colors.red}[OFF]${screen_1.ansi.reset}`;
107
107
  lines.push(` ${pointer} ${toggle} ${screen_1.ansi.bold}${entry.name}${screen_1.ansi.reset} ${screen_1.ansi.dim}${entry.description}${screen_1.ansi.reset}`);
108
108
  }
109
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
109
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
110
110
  return lines;
111
111
  }
112
112
  function renderGearOverlay(state) {
@@ -117,9 +117,9 @@ function renderGearOverlay(state) {
117
117
  const boots = s.equippedBoots ? (0, items_1.getItem)(s.equippedBoots) : null;
118
118
  const accessory = s.equippedAccessory ? (0, items_1.getItem)(s.equippedAccessory) : null;
119
119
  const lines = [];
120
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
120
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
121
121
  lines.push(` ${screen_1.ansi.bold}Equipped Gear${screen_1.ansi.reset} ${screen_1.ansi.dim}Esc to close${screen_1.ansi.reset}`);
122
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
122
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
123
123
  lines.push(` ⚔ Weapon: ${weapon ? `${weapon.name} (+${weapon.combatATK} ATK)${weapon.gearElement ? ` [${weapon.gearElement}]` : ""}` : `${screen_1.ansi.dim}None${screen_1.ansi.reset}`}`);
124
124
  if (weapon?.onHitEffect)
125
125
  lines.push(` ${screen_1.ansi.dim}${Math.round((weapon.onHitChance ?? 0) * 100)}% ${weapon.onHitEffect} on hit${screen_1.ansi.reset}`);
@@ -129,7 +129,7 @@ function renderGearOverlay(state) {
129
129
  lines.push(` 🪖 Helmet: ${helmet ? `${helmet.name} (${helmet.description})` : `${screen_1.ansi.dim}None${screen_1.ansi.reset}`}`);
130
130
  lines.push(` 👢 Boots: ${boots ? `${boots.name} (${boots.description})` : `${screen_1.ansi.dim}None${screen_1.ansi.reset}`}`);
131
131
  lines.push(` 💍 Acc: ${accessory ? `${accessory.name} (${accessory.description})` : `${screen_1.ansi.dim}None${screen_1.ansi.reset}`}`);
132
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
132
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
133
133
  lines.push(` ${screen_1.ansi.dim}Equip gear from /inventory${screen_1.ansi.reset}`);
134
134
  return lines;
135
135
  }
@@ -139,9 +139,9 @@ function renderCombatInfoOverlay(state) {
139
139
  const weapon = equippedWeapon ? (0, items_1.getItem)(equippedWeapon) : null;
140
140
  const armor = equippedArmor ? (0, items_1.getItem)(equippedArmor) : null;
141
141
  const lines = [];
142
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
142
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
143
143
  lines.push(` ${screen_1.ansi.bold}Combat Stats${screen_1.ansi.reset} ${screen_1.ansi.dim}Esc to close${screen_1.ansi.reset}`);
144
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
144
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
145
145
  lines.push(` HP: ${screen_1.ansi.colors.green}${stats.maxHp}${screen_1.ansi.reset}`);
146
146
  lines.push(` ATK: ${screen_1.ansi.colors.red}${stats.atk}${screen_1.ansi.reset}${weapon ? ` (base + ${weapon.combatATK} from ${weapon.name})` : ""}`);
147
147
  lines.push(` DEF: ${screen_1.ansi.colors.blue}${stats.def}${screen_1.ansi.reset}${armor ? ` (base + ${armor.combatDEF} from ${armor.name})` : ""}`);
@@ -150,15 +150,15 @@ function renderCombatInfoOverlay(state) {
150
150
  lines.push("");
151
151
  lines.push(` ${screen_1.ansi.dim}Adventures: ${state.adventureStats.adventuresCompleted} | Battles won: ${state.adventureStats.battlesWon}${screen_1.ansi.reset}`);
152
152
  lines.push(` ${screen_1.ansi.dim}Bosses defeated: ${state.adventureStats.bossesDefeated} | Perfect runs: ${state.adventureStats.perfectAdventures}${screen_1.ansi.reset}`);
153
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
153
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
154
154
  return lines;
155
155
  }
156
156
  function renderShopOverlay(state, overlay) {
157
157
  const lines = [];
158
158
  const gold = state.questProgress.gold;
159
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
159
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
160
160
  lines.push(` ${screen_1.ansi.bold}Pip's Shop${screen_1.ansi.reset} ${screen_1.ansi.colors.yellow}Gold: ${gold}${screen_1.ansi.reset} ${screen_1.ansi.dim}Esc to close${screen_1.ansi.reset}`);
161
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
161
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
162
162
  for (let i = 0; i < shop_1.SHOP_ITEMS.length; i++) {
163
163
  const shopItem = shop_1.SHOP_ITEMS[i];
164
164
  const item = (0, items_1.getItem)(shopItem.itemId);
@@ -177,7 +177,7 @@ function renderShopOverlay(state, overlay) {
177
177
  lines.push(` ${pointer} ${screen_1.ansi.dim}${item.name} ${shopItem.price}g${screen_1.ansi.reset}`);
178
178
  }
179
179
  }
180
- lines.push(` ${screen_1.ansi.dim}${"─".repeat(48)}${screen_1.ansi.reset}`);
180
+ lines.push(` ${screen_1.ansi.dim}${"─".repeat(screen_1.UI_WIDTH)}${screen_1.ansi.reset}`);
181
181
  lines.push(` ${screen_1.ansi.dim}Enter to buy${screen_1.ansi.reset}`);
182
182
  return lines;
183
183
  }
@@ -5,4 +5,10 @@ import { SceneItem } from "./items";
5
5
  * Returns an array of lines ready to render.
6
6
  */
7
7
  export declare function composeScene(buddyArt: string[], item: SceneItem | null, groundLine?: boolean): string[];
8
+ /**
9
+ * Compose two ASCII art blocks side-by-side with colors.
10
+ * Bottom-aligns the shorter art. Returns array of composed lines.
11
+ * Used for buddy+NPC in adventure rooms and buddy+enemy in combat.
12
+ */
13
+ export declare function composeDualArt(leftArt: string[], rightArt: string[], leftColor: string, rightColor: string, gap?: number, reset?: string): string[];
8
14
  //# sourceMappingURL=scene.d.ts.map
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.composeScene = composeScene;
4
+ exports.composeDualArt = composeDualArt;
4
5
  const SCENE_WIDTH = 50;
5
6
  /**
6
7
  * Compose a scene by placing buddy art and an optional item
@@ -84,4 +85,23 @@ function centerPad(str, targetWidth) {
84
85
  const leftPad = Math.floor((targetWidth - len) / 2);
85
86
  return " ".repeat(leftPad) + str;
86
87
  }
88
+ /**
89
+ * Compose two ASCII art blocks side-by-side with colors.
90
+ * Bottom-aligns the shorter art. Returns array of composed lines.
91
+ * Used for buddy+NPC in adventure rooms and buddy+enemy in combat.
92
+ */
93
+ function composeDualArt(leftArt, rightArt, leftColor, rightColor, gap = 6, reset = "\x1b[0m") {
94
+ const maxHeight = Math.max(leftArt.length, rightArt.length);
95
+ const paddedLeft = topPad(leftArt, maxHeight);
96
+ const paddedRight = topPad(rightArt, maxHeight);
97
+ const leftWidth = Math.max(...leftArt.map(visibleLength));
98
+ const gapStr = " ".repeat(gap);
99
+ const lines = [];
100
+ for (let i = 0; i < maxHeight; i++) {
101
+ const lLine = rightPad(paddedLeft[i], leftWidth);
102
+ const rLine = paddedRight[i];
103
+ lines.push(`${leftColor}${lLine}${reset}${gapStr}${rightColor}${rLine}${reset}`);
104
+ }
105
+ return lines;
106
+ }
87
107
  //# sourceMappingURL=scene.js.map
@@ -26,6 +26,14 @@ export declare const ansi: {
26
26
  altScreenOn: string;
27
27
  altScreenOff: string;
28
28
  };
29
+ /** Standard content width for dividers and framing */
30
+ export declare const UI_WIDTH = 48;
31
+ /** Strip ANSI escape codes for visible length calculation */
32
+ export declare function stripAnsi(s: string): string;
33
+ /** Visible length of a string (excluding ANSI codes) */
34
+ export declare function visibleLen(s: string): number;
35
+ /** Right-pad a string with spaces to target visible width */
36
+ export declare function visPad(s: string, targetWidth: number): string;
29
37
  export declare class Screen {
30
38
  private regions;
31
39
  private footer;
@@ -5,7 +5,10 @@
5
5
  * Redraws by moving to home and overwriting — no clear, no flicker.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.Screen = exports.ansi = void 0;
8
+ exports.Screen = exports.UI_WIDTH = exports.ansi = void 0;
9
+ exports.stripAnsi = stripAnsi;
10
+ exports.visibleLen = visibleLen;
11
+ exports.visPad = visPad;
9
12
  const ESC = "\x1b";
10
13
  exports.ansi = {
11
14
  clearScreen: `${ESC}[2J${ESC}[H`,
@@ -31,6 +34,21 @@ exports.ansi = {
31
34
  altScreenOn: `${ESC}[?1049h`,
32
35
  altScreenOff: `${ESC}[?1049l`,
33
36
  };
37
+ /** Standard content width for dividers and framing */
38
+ exports.UI_WIDTH = 48;
39
+ /** Strip ANSI escape codes for visible length calculation */
40
+ function stripAnsi(s) {
41
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
42
+ }
43
+ /** Visible length of a string (excluding ANSI codes) */
44
+ function visibleLen(s) {
45
+ return stripAnsi(s).length;
46
+ }
47
+ /** Right-pad a string with spaces to target visible width */
48
+ function visPad(s, targetWidth) {
49
+ const pad = targetWidth - visibleLen(s);
50
+ return pad > 0 ? s + " ".repeat(pad) : s;
51
+ }
34
52
  class Screen {
35
53
  regions = [];
36
54
  footer = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clibuddy",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A tamagotchi-style virtual pet that lives in your terminal — feed, play, adventure, and battle!",
5
5
  "main": "dist/index.js",
6
6
  "bin": {