Files
halfstreet/src/ui/terminal.ts
T

311 lines
10 KiB
TypeScript
Raw Normal View History

import { parse } from '../engine/parser'
import type { ParserContext } from '../engine/parser'
2026-05-09 01:03:47 -05:00
import { dispatch, initialStateFor, getItemsInRoom } from '../engine/dispatcher'
import { saveState, loadState, clearSave } from '../engine/save'
import { world } from '../world'
import type { GameState, TranscriptLine } from '../engine/types'
import { TRANSCRIPT_CAP } from '../engine/types'
import { computeChips } from './chips'
import { renderChips } from './chip-render'
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
2026-05-10 07:00:22 -05:00
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
This is a text adventure. Type short commands to act in the house.
Common commands:
look describe the room again
n, s, e, w, u, d move by direction
take lamp pick something up
examine letter inspect something nearby or held
read letter read a readable object
inventory see what you carry
light lamp with matches use one thing with another
wait let the room continue
undo step back once
restart begin again
theme change the terminal colors
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.`
2026-05-10 07:00:22 -05:00
if (!transcriptEl || !inputEl || !inputDisplayEl) {
console.error('[halfstreet] terminal mount points missing')
} else {
const restored = loadState()
let state: GameState = restored ?? initialStateFor(world)
let lastState: GameState | null = null // for one-step undo
let transientHelpEl: HTMLDivElement | null = null
2026-05-10 07:00:22 -05:00
let commandHistory: string[] = []
let historyIndex: number | null = null
let historyDraft = ''
let idleHintTimer: number | null = null
if (!restored) {
// Fresh state already includes the opening narration in its transcript.
} else if (restored.transcript.length === 0) {
// Edge case: a restored state with no transcript (older save discarded
// and we fell back to fresh — handled above — or a corrupted slice).
state = initialStateFor(world)
}
function refreshChips(): void {
renderChips(computeChips(state, world), (command) => {
2026-05-10 07:00:22 -05:00
clearIdleHint()
inputEl!.value = command
2026-05-10 07:00:22 -05:00
syncCommandLine()
if (command.endsWith(' ')) {
inputEl!.focus()
inputEl!.setSelectionRange(command.length, command.length)
return
}
inputEl!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
})
}
const syncEndedUI = (): void => {
// Don't disable the input — the player still needs to type `restart` or
// `undo`. A `disabled` input rejects keydown events entirely. Use a class
// for visual styling instead; the keydown handler enforces the input
// restriction.
inputEl!.classList.toggle('ended', state.endedWith !== null)
}
2026-05-10 07:00:22 -05:00
const syncCommandLine = (): void => {
const visibleText = inputEl.value || inputEl.placeholder
inputDisplayEl.textContent = visibleText
inputDisplayEl.dataset['placeholder'] = inputEl.value ? 'false' : inputEl.placeholder ? 'true' : 'false'
}
const buildParserContext = (s: GameState): ParserContext => {
const room = world.rooms[s.location]
const visibleNouns: { id: string; aliases: string[] }[] = []
if (room) {
2026-05-09 01:03:47 -05:00
for (const id of getItemsInRoom(s, world, s.location)) {
const it = world.items[id]
if (it) visibleNouns.push({ id, aliases: it.names })
}
if (room.encounter && s.encounterState[room.encounter]) {
const encounter = world.encounters[room.encounter]
visibleNouns.push({
id: room.encounter,
aliases: [room.encounter, room.encounter.replace(/-/g, ' '), ...(encounter?.aliases ?? [])],
})
}
}
for (const inst of s.inventory) {
const it = world.items[inst.id]
if (it) visibleNouns.push({ id: inst.id, aliases: it.names })
}
return {
knownItems: Object.keys(world.items),
knownEncounters: Object.keys(world.encounters),
visibleNouns,
inventoryItemIds: s.inventory.map((i) => i.id),
lastNoun: s.lastNoun,
awaitingDisambiguation: s.pendingDisambiguation,
}
}
const renderAll = (lines: TranscriptLine[]): void => {
if (!transcriptEl) return
for (const line of lines) {
const el = document.createElement('div')
el.className = line.kind
if (line.kind === 'system' && line.text.includes('|_| |_|')) {
el.classList.add('ascii-art')
}
el.textContent = line.text
transcriptEl.appendChild(el)
}
transcriptEl.scrollTop = transcriptEl.scrollHeight
}
2026-05-10 07:00:22 -05:00
const clearIdleHint = (): void => {
if (idleHintTimer !== null) {
window.clearTimeout(idleHintTimer)
idleHintTimer = null
}
inputEl.placeholder = ''
syncCommandLine()
}
const scheduleIdleHint = (): void => {
clearIdleHint()
idleHintTimer = window.setTimeout(() => {
inputEl.placeholder = 'type here...'
syncCommandLine()
}, 30000)
}
const clearTransientHelp = (): void => {
transientHelpEl?.remove()
transientHelpEl = null
}
const renderTransientHelp = (): void => {
if (!transcriptEl) return
clearTransientHelp()
const el = document.createElement('div')
el.className = 'system help'
el.dataset.transientHelp = 'true'
el.textContent = HELP_TEXT
transcriptEl.appendChild(el)
transientHelpEl = el
transcriptEl.scrollTop = transcriptEl.scrollHeight
}
// For UI-originated lines (player input, restart/undo/quit messages, error
// notices). Pushes into state.transcript so they survive reload, then renders.
// Engine-originated lines (from dispatch) are already in state.transcript;
// those use renderAll directly.
const appendLines = (lines: TranscriptLine[]): void => {
state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) }
renderAll(lines)
}
2026-05-10 07:00:22 -05:00
const restart = (): void => {
const confirmed = confirm('Restart? Your progress will be lost.')
if (!confirmed) {
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
return
}
clearSave()
state = initialStateFor(world)
transcriptEl.innerHTML = ''
inputEl.value = ''
syncCommandLine()
renderAll(state.transcript)
saveState(state)
refreshChips()
syncEndedUI()
}
renderAll(state.transcript)
refreshChips()
syncEndedUI()
2026-05-10 07:00:22 -05:00
syncCommandLine()
scheduleIdleHint()
inputEl.addEventListener('keydown', (e) => {
2026-05-10 07:00:22 -05:00
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (commandHistory.length === 0) return
e.preventDefault()
if (historyIndex === null) {
historyDraft = inputEl.value
historyIndex = commandHistory.length
}
if (e.key === 'ArrowUp') {
historyIndex = Math.max(0, historyIndex - 1)
} else {
historyIndex = Math.min(commandHistory.length, historyIndex + 1)
}
inputEl.value = historyIndex === commandHistory.length ? historyDraft : commandHistory[historyIndex]!
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
syncCommandLine()
return
}
if (e.key !== 'Enter') return
e.preventDefault()
const raw = inputEl.value
inputEl.value = ''
2026-05-10 07:00:22 -05:00
syncCommandLine()
if (!raw.trim()) return
clearTransientHelp()
2026-05-10 07:00:22 -05:00
clearIdleHint()
commandHistory = [...commandHistory, raw].slice(-50)
historyIndex = null
historyDraft = ''
appendLines([{ kind: 'player', text: raw }])
// Once the game has ended, only restart and undo are allowed.
if (state.endedWith !== null) {
const lower = raw.trim().toLowerCase()
if (lower !== 'restart' && lower !== 'undo') {
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }])
return
}
}
// Engine-level meta-commands handled here so the engine stays pure.
const trimmed = raw.trim().toLowerCase()
if (trimmed === 'restart') {
2026-05-10 07:00:22 -05:00
restart()
return
}
if (trimmed === 'help') {
renderTransientHelp()
return
}
if (trimmed === 'undo') {
if (lastState) {
state = lastState
lastState = null
appendLines([{ kind: 'system', text: '(undone)' }])
saveState(state)
refreshChips()
syncEndedUI()
} else {
appendLines([{ kind: 'system', text: 'There is no further back.' }])
}
return
}
if (trimmed === 'quit') {
saveState(state)
window.location.href = '/'
return
}
// Engine dispatch — wrapped so a thrown error doesn't kill the terminal.
try {
const ctx = buildParserContext(state)
const command = parse(raw, ctx)
lastState = state
const result = dispatch(state, command, world)
state = result.state
renderAll(result.appended) // dispatch already pushed these into state.transcript
saveState(state)
transcriptEl.scrollTop = transcriptEl.scrollHeight
if (raw.trim().toLowerCase() === 'theme') {
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
}
refreshChips()
syncEndedUI()
} catch (err) {
console.error('[halfstreet] dispatch error', err)
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
}
})
2026-05-10 07:00:22 -05:00
inputEl.addEventListener('input', syncCommandLine)
inputEl.addEventListener('focus', clearIdleHint)
inputEl.addEventListener('pointerdown', clearIdleHint)
inputEl.parentElement?.addEventListener('pointerdown', () => {
inputEl.focus()
})
document.addEventListener('keydown', (e) => {
2026-05-10 07:00:22 -05:00
const target = e.target as HTMLElement | null
const isEditable =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable === true
if (e.key === '/' && !isEditable) {
e.preventDefault()
clearIdleHint()
inputEl.focus()
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
return
}
if (e.key === 'Escape') {
saveState(state)
window.location.href = '/'
}
})
2026-05-10 07:00:22 -05:00
document.addEventListener('halfstreet-restart', restart)
}