smolworld / src /js /terminal.js
p3nGu1nZz's picture
✨ Refactor physics module; add math utilities and event handling classes; update state structure for 3D entities
7ffaa9e
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
};
}