import { getConfig } from './config.js'; import { Time } from './time.js'; import { state } from './state.js'; import { drawPanel } from './panel.js'; import { GUI, TextArea, ScrollBar } from './gui.js'; import { LayerType } from './layer.js'; // Add this import import { EventTypes } from './event.js'; // Add this import export function createTerminal(canvas, ctx, inputHandler) { const config = getConfig(); let targetHeight = 0; let animationStartTime = 0; let animating = false; let animationStartHeight = 0; // Create GUI components const textArea = new TextArea({ font: config.terminal.font, lineHeight: config.terminal.lineHeight }); const scrollBar = new ScrollBar({ width: config.terminal.scrollBarWidth, margin: config.terminal.scrollBarMargin, minThumbHeight: config.terminal.scrollThumbMinHeight }); function getMaxVisibleLines() { // Account for input line height and bottom margin const inputAreaHeight = config.terminal.lineHeight * 1.5; // Space for input line const usableHeight = state.terminalHeight - inputAreaHeight - config.terminal.panelMarginY * 2; return Math.floor(usableHeight / config.terminal.lineHeight); } function hasOverflow() { const maxLines = getMaxVisibleLines(); const totalLines = state.commandHistory.length + 1; // +1 for input line return totalLines > maxLines; } function scrollToBottom() { const maxLines = getMaxVisibleLines(); const totalLines = state.commandHistory.length + 1; state.maxScrollOffset = Math.max(0, totalLines - maxLines); state.scrollOffset = 0; // Reset to bottom } function updateScrollOffset() { const maxLines = getMaxVisibleLines(); const totalLines = state.commandHistory.length + 1; // +1 for input line if (totalLines <= maxLines) { // Reset scroll when no overflow state.maxScrollOffset = 0; state.scrollOffset = 0; return; } state.maxScrollOffset = Math.max(0, totalLines - maxLines); scrollToBottom(); } function updateScroll(event) { if (!state.isDraggingScrollbar) return; const deltaY = event.clientY - state.lastMouseY; state.lastMouseY = event.clientY; state.scrollOffset = Math.max(0, Math.min(state.maxScrollOffset, state.scrollOffset - deltaY / 10)); event.preventDefault(); event.stopPropagation(); } function isPointInTerminal(x, y) { if (!state.terminalOpen) return false; return y <= state.terminalHeight; } function startScroll(event) { if (!state.terminalOpen) return; const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; if (!isPointInTerminal(x, y)) return; const scrollBarX = canvas.width - config.terminal.scrollBarWidth - config.terminal.scrollBarMargin * 2; if (x >= scrollBarX && x <= scrollBarX + config.terminal.scrollBarWidth) { state.isDraggingScrollbar = true; state.lastMouseY = event.clientY; event.preventDefault(); event.stopPropagation(); } } function updateAnimation() { if (!animating) return; const elapsed = (performance.now() - animationStartTime) / 1000; const progress = Math.min(elapsed / config.terminal.animationSpeed, 1); // Smooth easing const eased = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress; state.terminalHeight = animationStartHeight + (targetHeight - animationStartHeight) * eased; if (progress >= 1) { animating = false; state.terminalHeight = targetHeight; } } function drawTerminal() { updateAnimation(); ctx.save(); if (state.terminalOpen || animating) { // Position panel with margin const panelX = config.terminal.panelMargin; const panelY = config.terminal.panelMargin; const panelWidth = canvas.width - (config.terminal.panelMargin * 2); const panelHeight = state.terminalHeight - (config.terminal.panelMargin * 2); drawPanel(ctx, panelX, panelY, panelWidth, panelHeight, config.terminal.margin); // Draw terminal content background with padding const contentX = panelX + config.terminal.margin + config.terminal.panelPadding; const contentY = panelY + config.terminal.margin + config.terminal.panelPadding; const contentWidth = panelWidth - (config.terminal.margin * 2) - (config.terminal.panelPadding * 2); const contentHeight = panelHeight - (config.terminal.margin * 2) - (config.terminal.panelPadding * 2); ctx.globalAlpha = config.terminalOpacity; ctx.fillStyle = 'rgba(0, 0, 0, 0.95)'; ctx.fillRect(contentX, contentY, contentWidth, contentHeight); } ctx.restore(); } function drawTerminalText() { if (!state.terminalOpen) return; ctx.save(); // Update cursor blink state.cursorTimer += Time.deltaTime; if (state.cursorTimer >= config.terminal.cursorBlinkRate) { state.cursorTimer = 0; state.cursorVisible = !state.cursorVisible; } // Calculate available space considering panel padding const contentStart = config.terminal.panelMargin + config.terminal.margin + config.terminal.panelPadding; const inputAreaHeight = config.terminal.lineHeight * 1.5; const textAreaHeight = state.terminalHeight - inputAreaHeight - (contentStart * 2); // Update text area textArea.setLines(state.commandHistory); textArea.setVisibleLines(Math.floor(textAreaHeight / config.terminal.lineHeight)); textArea.scrollOffset = state.scrollOffset; // Draw command history with proper padding textArea.draw(ctx, contentStart + 5, // Extra 5px indent for text contentStart, canvas.width - config.terminal.scrollBarWidth - contentStart * 2, textAreaHeight ); // Draw input panel top border and input field - always visible const inputPanelY = state.terminalHeight - config.terminal.panelMargin - config.terminal.margin - config.terminal.panelPadding - config.terminal.lineHeight * 1.8; ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; ctx.fillRect( contentStart, inputPanelY, canvas.width - (contentStart * 2), 1 ); // Always draw input line, with adjusted Y position (moved down 5px) const inputText = '> ' + state.inputBuffer; const lastLineY = inputPanelY + config.terminal.lineHeight + 5; // Added 5px here GUI.drawText(ctx, inputText, contentStart + 5, lastLineY, { font: config.terminal.font }); if (state.cursorVisible) { const metrics = GUI.measureText(ctx, inputText, config.terminal.font); GUI.drawTextCursor(ctx, contentStart + 5 + metrics.width + 1, // Add 1px margin from text lastLineY + 4, // Move cursor down 4px { height: config.terminal.lineHeight * 0.8 } ); } // Draw scrollbar with proper alignment if (hasOverflow()) { const scrollRatio = state.maxScrollOffset > 0 ? 1 - (state.scrollOffset / state.maxScrollOffset) : 1; const viewportRatio = textArea.visibleLines / textArea.lines.length; scrollBar.draw(ctx, canvas.width - contentStart - config.terminal.scrollBarWidth, contentStart, textAreaHeight, scrollRatio, viewportRatio ); } ctx.restore(); } function handleTextInput(char) { if (state.terminalOpen && char !== '`') { state.inputBuffer += char; } } function handleToggleTerminal(event) { if (event.shiftKey) { toggleTerminal(true); } else { toggleTerminal(); } } function toggleTerminal(full = false) { state.terminalOpen = !state.terminalOpen; if (state.scene) { const gameLayer = state.scene.getLayer(LayerType.GAME); const debugLayer = state.scene.getLayer(LayerType.DEBUG); const guiLayer = state.scene.getLayer(LayerType.GUI); if (state.terminalOpen) { // Disable other layers' input when terminal is open gameLayer?.disableInput(); debugLayer?.disableInput(); guiLayer?.disableInput(); // Also disable camera controls state.inputHandler.disableCameraControls(); inputHandler.addTextInputListener(handleTextInput); updateScrollOffset(); } else { // Re-enable other layers' input when terminal is closed gameLayer?.enableInput(); debugLayer?.enableInput(); guiLayer?.enableInput(); // Re-enable camera controls state.inputHandler.enableCameraControls(); inputHandler.removeTextInputListener(handleTextInput); state.scrollOffset = 0; state.maxScrollOffset = 0; state.isDraggingScrollbar = false; } } // Animation setup animating = true; animationStartTime = performance.now(); animationStartHeight = state.terminalHeight; targetHeight = state.terminalOpen ? canvas.height * config.terminal.heightRatio : 0; } function executeCommand(command) { const trimmedCommand = command.trim(); state.commandHistory.push('> ' + command); state.inputBuffer = ''; switch (trimmedCommand) { case 'help': state.commandHistory.push('Available commands:'); state.commandHistory.push(' help - Shows this help message'); state.commandHistory.push(' about - About this smolworld'); state.commandHistory.push(' clear - Clears the terminal'); break; case 'about': state.commandHistory.push('Smolworld: A tiny world simulation.'); break; case 'clear': state.commandHistory = []; break; default: state.commandHistory.push('Unknown command: ' + command); } updateScrollOffset(); } function handleEnter() { executeCommand(state.inputBuffer); } function handleBackspace() { state.inputBuffer = state.inputBuffer.slice(0, -1); } // Replace setupTerminalControls with direct event listeners inputHandler.listenForKey('`', (event) => { handleToggleTerminal(event); }); inputHandler.listenForKey('Enter', () => { if (state.terminalOpen) { handleEnter(); } }); inputHandler.listenForKey('Backspace', () => { if (state.terminalOpen) { handleBackspace(); } }); // Setup mouse handlers through event system inputHandler.on(EventTypes.MOUSE_DOWN, startScroll); inputHandler.on(EventTypes.MOUSE_MOVE, updateScroll); inputHandler.on(EventTypes.MOUSE_UP, (event) => { if (state.isDraggingScrollbar) { event.preventDefault(); event.stopPropagation(); } state.isDraggingScrollbar = false; }); inputHandler.on(EventTypes.MOUSE_WHEEL, (event) => { const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; if (!state.terminalOpen || !isPointInTerminal(x, y) || !hasOverflow()) return; event.preventDefault(); event.stopPropagation(); const scrollAmount = event.deltaY > 0 ? -1 : 1; state.scrollOffset = Math.max(0, Math.min(state.maxScrollOffset, state.scrollOffset + scrollAmount)); }); return { draw: drawTerminal, drawText: drawTerminalText, // Export methods needed by input handler handleToggleTerminal, handleEnter, handleBackspace, startScroll, updateScroll, isPointInTerminal, hasOverflow }; }