From 25815aa4d22c2849e2e05eb7e685b4fc46b07e1b Mon Sep 17 00:00:00 2001 From: Dan Date: Tue, 20 Jan 2026 07:36:44 +0000 Subject: [PATCH] adding the table helper --- assets/js/games/engine/scene-manager.js | 63 +++++++ assets/js/games/engine/table-helper.js | 241 ++++++++++++++++++++++++ assets/js/games/games/boxing-day.js | 78 +++++--- 3 files changed, 359 insertions(+), 23 deletions(-) create mode 100644 assets/js/games/engine/table-helper.js diff --git a/assets/js/games/engine/scene-manager.js b/assets/js/games/engine/scene-manager.js index aa699fe..412f38a 100644 --- a/assets/js/games/engine/scene-manager.js +++ b/assets/js/games/engine/scene-manager.js @@ -149,6 +149,11 @@ class SceneManager { continue; } + if (block.type === "table") { + await this._renderTable(block); + continue; + } + // Text with optional className (supports html: true for HTML content) if (block.text !== undefined) { if (block.html) { @@ -379,6 +384,64 @@ class SceneManager { return new Promise((resolve) => setTimeout(resolve, ms)); } + // Render a dynamic table with conditional row support + async _renderTable(block) { + // Filter rows based on conditions + const filteredRows = []; + for (const row of block.rows || []) { + // Row can be: array of cells, or object with { cells, condition, className } + if (Array.isArray(row)) { + // Simple row - array of cells + filteredRows.push(row); + } else if (row.condition !== undefined) { + // Conditional row + if (this.state.evaluate(row.condition)) { + filteredRows.push(this._processTableRow(row)); + } + } else if (row.cells) { + // Object row without condition + filteredRows.push(this._processTableRow(row)); + } + } + + // Build table using TableHelper + const tableOutput = TableHelper.table({ + title: block.title ? this._interpolateText(block.title) : undefined, + headers: block.headers, + rows: filteredRows, + widths: block.widths, + align: block.align, + style: block.style || "single", + padding: block.padding, + }); + + // Render each line of the table + for (const line of tableOutput) { + if (typeof line === "string") { + this._printText(line); + } else if (line.text) { + this._printText(line.text, line.className || ""); + } + } + } + + // Process a table row object into cell array format for TableHelper + _processTableRow(row) { + // If row has className, apply it to cells that don't have their own + const cells = row.cells.map((cell) => { + if (typeof cell === "string" && row.className) { + return { text: this._interpolateText(cell), className: row.className }; + } else if (typeof cell === "object" && cell.text) { + return { + text: this._interpolateText(cell.text), + className: cell.className || row.className, + }; + } + return typeof cell === "string" ? this._interpolateText(cell) : cell; + }); + return cells; + } + // Get current scene ID getCurrentSceneId() { return this.currentScene ? this.currentScene.id : null; diff --git a/assets/js/games/engine/table-helper.js b/assets/js/games/engine/table-helper.js new file mode 100644 index 0000000..65f4ab0 --- /dev/null +++ b/assets/js/games/engine/table-helper.js @@ -0,0 +1,241 @@ +// Table Helper - Generates monospace box-drawn tables for terminal games + +const TableHelper = { + // Box drawing characters + chars: { + single: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + leftT: "├", + rightT: "┤", + topT: "┬", + bottomT: "┴", + cross: "┼", + }, + double: { + topLeft: "╔", + topRight: "╗", + bottomLeft: "╚", + bottomRight: "╝", + horizontal: "═", + vertical: "║", + leftT: "╠", + rightT: "╣", + topT: "╦", + bottomT: "╩", + cross: "╬", + }, + ascii: { + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + horizontal: "-", + vertical: "|", + leftT: "+", + rightT: "+", + topT: "+", + bottomT: "+", + cross: "+", + }, + }, + + /** + * Generate a complete table with headers and rows + * @param {Object} options - Table configuration + * @param {string} [options.title] - Optional title for the table + * @param {string[]} [options.headers] - Column headers + * @param {Array>} options.rows - Table data rows + * @param {number[]} [options.widths] - Column widths (auto-calculated if not provided) + * @param {string[]} [options.align] - Column alignments ('left', 'right', 'center') + * @param {string} [options.style='single'] - Border style ('single', 'double', 'ascii') + * @param {number} [options.padding=1] - Cell padding + * @returns {Array} Array of content blocks + */ + table(options) { + const { + title, + headers, + rows = [], + widths: customWidths, + align = [], + style = "single", + padding = 1, + } = options; + + const c = this.chars[style] || this.chars.single; + const output = []; + + // Calculate column widths + const widths = customWidths || this._calculateWidths(headers, rows, padding); + const totalWidth = widths.reduce((a, b) => a + b, 0) + widths.length + 1; + + // Top border + output.push(c.topLeft + c.horizontal.repeat(totalWidth - 2) + c.topRight); + + // Title (if provided) + if (title) { + output.push(this._centerText(title, totalWidth, c.vertical)); + output.push(c.leftT + c.horizontal.repeat(totalWidth - 2) + c.rightT); + } + + // Headers (if provided) + if (headers) { + output.push(this._formatRow(headers, widths, align, c.vertical, padding)); + output.push(c.leftT + c.horizontal.repeat(totalWidth - 2) + c.rightT); + } + + // Data rows + for (const row of rows) { + const formattedRow = this._formatRow(row, widths, align, c.vertical, padding); + + // Check if any cell has a className + const hasClassName = row.some(cell => cell && typeof cell === "object" && cell.className); + + if (hasClassName) { + // Find the className from the row (use first one found) + const className = row.find(cell => cell && typeof cell === "object" && cell.className)?.className; + output.push({ text: formattedRow, className }); + } else { + output.push(formattedRow); + } + } + + // Bottom border + output.push(c.bottomLeft + c.horizontal.repeat(totalWidth - 2) + c.bottomRight); + + return output; + }, + + /** + * Generate a simple bordered box with text + * @param {string|string[]} content - Content to display + * @param {Object} [options] - Box options + * @param {number} [options.width] - Box width (auto if not set) + * @param {string} [options.style='single'] - Border style + * @param {string} [options.align='left'] - Text alignment + * @returns {string[]} Array of strings + */ + box(content, options = {}) { + const { width: customWidth, style = "single", align = "left" } = options; + const c = this.chars[style] || this.chars.single; + const lines = Array.isArray(content) ? content : [content]; + + const maxLineWidth = Math.max(...lines.map(l => this._textLength(l))); + const width = customWidth || maxLineWidth + 4; + const innerWidth = width - 2; + + const output = []; + output.push(c.topLeft + c.horizontal.repeat(innerWidth) + c.topRight); + + for (const line of lines) { + const text = typeof line === "string" ? line : line.text || ""; + const padded = this._alignText(text, innerWidth - 2, align); + output.push(c.vertical + " " + padded + " " + c.vertical); + } + + output.push(c.bottomLeft + c.horizontal.repeat(innerWidth) + c.bottomRight); + return output; + }, + + /** + * Generate a separator line + * @param {number} width - Line width + * @param {string} [style='single'] - Border style + * @param {string} [type='middle'] - 'top', 'middle', 'bottom' + * @returns {string} + */ + separator(width, style = "single", type = "middle") { + const c = this.chars[style] || this.chars.single; + const inner = c.horizontal.repeat(width - 2); + + switch (type) { + case "top": + return c.topLeft + inner + c.topRight; + case "bottom": + return c.bottomLeft + inner + c.bottomRight; + default: + return c.leftT + inner + c.rightT; + } + }, + + /** + * Pad or truncate text to exact width + * @param {string} text - Input text + * @param {number} width - Target width + * @param {string} [align='left'] - Alignment + * @returns {string} + */ + pad(text, width, align = "left") { + return this._alignText(text, width, align); + }, + + // Internal helpers + + _calculateWidths(headers, rows, padding) { + const allRows = headers ? [headers, ...rows] : rows; + const numCols = Math.max(...allRows.map(r => r.length)); + const widths = new Array(numCols).fill(0); + + for (const row of allRows) { + for (let i = 0; i < row.length; i++) { + const cell = row[i]; + const text = typeof cell === "object" ? (cell.text || "") : String(cell || ""); + widths[i] = Math.max(widths[i], text.length + padding * 2); + } + } + + return widths; + }, + + _formatRow(row, widths, alignments, verticalChar, padding) { + const cells = []; + for (let i = 0; i < widths.length; i++) { + const cell = row[i]; + const text = typeof cell === "object" ? (cell.text || "") : String(cell || ""); + const align = alignments[i] || "left"; + const cellWidth = widths[i] - padding * 2; + const padChar = " ".repeat(padding); + cells.push(padChar + this._alignText(text, cellWidth, align) + padChar); + } + return verticalChar + cells.join(verticalChar) + verticalChar; + }, + + _centerText(text, totalWidth, verticalChar) { + const innerWidth = totalWidth - 2; + const padded = this._alignText(text, innerWidth, "center"); + return verticalChar + padded + verticalChar; + }, + + _alignText(text, width, align) { + const len = text.length; + if (len >= width) { + return text.substring(0, width); + } + + const diff = width - len; + switch (align) { + case "right": + return " ".repeat(diff) + text; + case "center": + const left = Math.floor(diff / 2); + const right = diff - left; + return " ".repeat(left) + text + " ".repeat(right); + default: + return text + " ".repeat(diff); + } + }, + + _textLength(item) { + if (typeof item === "string") return item.length; + if (item && item.text) return item.text.length; + return 0; + }, +}; + +// Make available globally +window.TableHelper = TableHelper; diff --git a/assets/js/games/games/boxing-day.js b/assets/js/games/games/boxing-day.js index 488c61b..9bfeba1 100644 --- a/assets/js/games/games/boxing-day.js +++ b/assets/js/games/games/boxing-day.js @@ -201,29 +201,24 @@ const boxingDayGame = { read_messages: { title: "Private Messages", content: [ - "┌──────────────────────────────────────────────┐", - "│ D A R K T O W E R B B S │", - "├──────────────────────────────────────────────┤", - "│ Private Mail for: 0BSERVER0 │", - "├──────────────────────────────────────────────┤", - "│ # FROM TO DATE STATUS │", - "├──────────────────────────────────────────────┤", - { - text: "| 23 [UNKNOWN] 0BSERVER0 25/12 NEW |", - className: "warning", - }, - { - text: "| 22 NIGHTWAT. 0BSERVER0 12/12 READ |", - }, - { - text: "| 21 0BSERVER0 NIGHTWAT. 11/12 SENT |", - }, - { - text: "| 22 NIGHTWAT. 0BSERVER0 10/12 READ |", - }, - "└──────────────────────────────────────────────┘", - "", - "", + ...TableHelper.table({ + title: "Private Messages for 0BSERVER0", + headers: ["#", "FROM", "TO", "DATE", "STATUS"], + rows: [ + [ + "23", + "[UNKNOWN]", + "0BSERVER0", + "25/12", + { text: "NEW", className: "warning" }, + ], + ["22", "NIGHTWATCHER", "0BSERVER0", "12/12", "READ"], + ["21", "0BSERVER0", "NIGHTWATCHER", "11/12", "SENT"], + ["22", "NIGHTWATCHER", "0BSERVER0", "10/12", "READ"], + ], + widths: [4, 12, 12, 8, 8], + align: ["right", "left", "left", "left", "left"], + }), ], options: [ { text: "Open unread message", next: "new_message" }, @@ -308,6 +303,43 @@ const boxingDayGame = { message_archive: { title: "Message Archive", content: [ + { + type: "table", + title: "Private Messages for ${username}", // Supports interpolation + headers: ["#", "FROM", "TO", "DATE", "STATUS"], + rows: [ + // Simple array row (always shown) + ["22", "NIGHTWAT.", "0BSERVER0", "12/12", "READ"], + + // Conditional row - only shown if condition is true + { + condition: { not: "read_new_message" }, + cells: ["23", "[UNKNOWN]", "0BSERVER0", "25/12", "NEW"], + className: "warning", // Applied to all cells in row + }, + + // Row with per-cell styling + { + cells: [ + "21", + "0BSERVER0", + { text: "DELETED", className: "error" }, + "11/12", + "SENT", + ], + }, + + // Conditional with complex logic + { + condition: { and: ["has_secret", { not: "revealed_secret" }] }, + cells: ["99", "???", "???", "??/??", "HIDDEN"], + className: "error", + }, + ], + widths: [4, 12, 12, 8, 8], + align: ["right", "left", "left", "left", "left"], + style: "single", // "single", "double", or "ascii" + }, "═══ ARCHIVED MESSAGES ═══", "", {