|
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'; |
|
import { EventTypes } from './event.js'; |
|
|
|
export function createTerminal(canvas, ctx, inputHandler) { |
|
const config = getConfig(); |
|
let targetHeight = 0; |
|
let animationStartTime = 0; |
|
let animating = false; |
|
let animationStartHeight = 0; |
|
|
|
|
|
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() { |
|
|
|
const inputAreaHeight = config.terminal.lineHeight * 1.5; |
|
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; |
|
return totalLines > maxLines; |
|
} |
|
|
|
function scrollToBottom() { |
|
const maxLines = getMaxVisibleLines(); |
|
const totalLines = state.commandHistory.length + 1; |
|
state.maxScrollOffset = Math.max(0, totalLines - maxLines); |
|
state.scrollOffset = 0; |
|
} |
|
|
|
function updateScrollOffset() { |
|
const maxLines = getMaxVisibleLines(); |
|
const totalLines = state.commandHistory.length + 1; |
|
|
|
if (totalLines <= maxLines) { |
|
|
|
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); |
|
|
|
|
|
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) { |
|
|
|
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); |
|
|
|
|
|
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(); |
|
|
|
|
|
state.cursorTimer += Time.deltaTime; |
|
if (state.cursorTimer >= config.terminal.cursorBlinkRate) { |
|
state.cursorTimer = 0; |
|
state.cursorVisible = !state.cursorVisible; |
|
} |
|
|
|
|
|
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); |
|
|
|
|
|
textArea.setLines(state.commandHistory); |
|
textArea.setVisibleLines(Math.floor(textAreaHeight / config.terminal.lineHeight)); |
|
textArea.scrollOffset = state.scrollOffset; |
|
|
|
|
|
textArea.draw(ctx, |
|
contentStart + 5, |
|
contentStart, |
|
canvas.width - config.terminal.scrollBarWidth - contentStart * 2, |
|
textAreaHeight |
|
); |
|
|
|
|
|
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 |
|
); |
|
|
|
|
|
const inputText = '> ' + state.inputBuffer; |
|
const lastLineY = inputPanelY + config.terminal.lineHeight + 5; |
|
|
|
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, |
|
lastLineY + 4, |
|
{ |
|
height: config.terminal.lineHeight * 0.8 |
|
} |
|
); |
|
} |
|
|
|
|
|
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) { |
|
|
|
gameLayer?.disableInput(); |
|
debugLayer?.disableInput(); |
|
guiLayer?.disableInput(); |
|
|
|
|
|
state.inputHandler.disableCameraControls(); |
|
inputHandler.addTextInputListener(handleTextInput); |
|
updateScrollOffset(); |
|
} else { |
|
|
|
gameLayer?.enableInput(); |
|
debugLayer?.enableInput(); |
|
guiLayer?.enableInput(); |
|
|
|
|
|
state.inputHandler.enableCameraControls(); |
|
inputHandler.removeTextInputListener(handleTextInput); |
|
state.scrollOffset = 0; |
|
state.maxScrollOffset = 0; |
|
state.isDraggingScrollbar = false; |
|
} |
|
} |
|
|
|
|
|
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); |
|
} |
|
|
|
|
|
inputHandler.listenForKey('`', (event) => { |
|
handleToggleTerminal(event); |
|
}); |
|
|
|
inputHandler.listenForKey('Enter', () => { |
|
if (state.terminalOpen) { |
|
handleEnter(); |
|
} |
|
}); |
|
|
|
inputHandler.listenForKey('Backspace', () => { |
|
if (state.terminalOpen) { |
|
handleBackspace(); |
|
} |
|
}); |
|
|
|
|
|
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, |
|
|
|
handleToggleTerminal, |
|
handleEnter, |
|
handleBackspace, |
|
startScroll, |
|
updateScroll, |
|
isPointInTerminal, |
|
hasOverflow |
|
}; |
|
} |
|
|