This commit is contained in:
+145
-10
@@ -26,6 +26,28 @@ body {
|
||||
--m-divider-style: double;
|
||||
}
|
||||
|
||||
:root[data-mystery-cursor='bar'],
|
||||
:root:not([data-mystery-cursor]) {
|
||||
--m-cursor-preview: '|';
|
||||
--m-cursor-preview-size: 1em;
|
||||
--m-cursor-preview-weight: normal;
|
||||
--m-cursor-preview-offset: 0;
|
||||
}
|
||||
|
||||
:root[data-mystery-cursor='block'] {
|
||||
--m-cursor-preview: '█';
|
||||
--m-cursor-preview-size: 0.9em;
|
||||
--m-cursor-preview-weight: normal;
|
||||
--m-cursor-preview-offset: 0;
|
||||
}
|
||||
|
||||
:root[data-mystery-cursor='underscore'] {
|
||||
--m-cursor-preview: '_';
|
||||
--m-cursor-preview-size: 1.15em;
|
||||
--m-cursor-preview-weight: bold;
|
||||
--m-cursor-preview-offset: 0.18em;
|
||||
}
|
||||
|
||||
.mystery-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -93,17 +115,73 @@ body {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mystery-theme-toggle {
|
||||
.mystery-options {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
right: 32px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mystery-theme-toggle button {
|
||||
.mystery-options-toggle {
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mystery-options-icon {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
background: currentColor;
|
||||
display: block;
|
||||
mask: var(--gear-icon) center / contain no-repeat;
|
||||
-webkit-mask: var(--gear-icon) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.mystery-options-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
padding: 8px;
|
||||
background: var(--m-bg);
|
||||
border: 1px solid var(--m-dim);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.mystery-options-menu::after {
|
||||
content: 'Cursor ' var(--m-cursor-preview);
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid var(--m-dim);
|
||||
color: var(--m-fg);
|
||||
font-size: 12px;
|
||||
font-weight: var(--m-cursor-preview-weight);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mystery-options-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mystery-options-group + .mystery-options-group {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mystery-options-label {
|
||||
flex: 0 0 100%;
|
||||
color: var(--m-dim);
|
||||
}
|
||||
|
||||
.mystery-options button {
|
||||
background: transparent;
|
||||
color: var(--m-dim);
|
||||
border: 1px solid var(--m-dim);
|
||||
@@ -113,7 +191,8 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mystery-theme-toggle button[aria-pressed='true'] {
|
||||
.mystery-options button[aria-pressed='true'],
|
||||
.mystery-options-toggle[aria-expanded='true'] {
|
||||
color: var(--m-fg);
|
||||
border-color: var(--m-fg);
|
||||
}
|
||||
@@ -123,6 +202,8 @@ body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: var(--m-dim) transparent;
|
||||
scrollbar-width: thin;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -131,6 +212,18 @@ body {
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.mystery-transcript::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.mystery-transcript::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mystery-transcript::-webkit-scrollbar-thumb {
|
||||
background: var(--m-dim);
|
||||
}
|
||||
|
||||
.mystery-transcript .system {
|
||||
color: var(--m-accent-1);
|
||||
font-weight: bold;
|
||||
@@ -172,22 +265,64 @@ body {
|
||||
margin-top: 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 1.45em;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.mystery-input-row::before {
|
||||
content: '>';
|
||||
color: var(--m-accent-2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mystery-input {
|
||||
flex: 1;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--m-fg);
|
||||
border: none;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
text-shadow: inherit;
|
||||
caret-color: var(--m-fg);
|
||||
}
|
||||
|
||||
.mystery-input-display {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--m-fg);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mystery-input-display[data-placeholder='true'] {
|
||||
color: var(--m-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mystery-input-display::after {
|
||||
content: var(--m-cursor-preview);
|
||||
display: inline-block;
|
||||
color: var(--m-fg);
|
||||
font-size: var(--m-cursor-preview-size);
|
||||
font-style: normal;
|
||||
font-weight: var(--m-cursor-preview-weight);
|
||||
line-height: 1;
|
||||
transform: translateY(var(--m-cursor-preview-offset));
|
||||
margin-left: 0.1ch;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mystery-input:focus + .mystery-input-display::after {
|
||||
animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes mystery-cursor-blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.mystery-chips {
|
||||
|
||||
+95
-14
@@ -10,6 +10,7 @@ import { renderChips } from './chip-render'
|
||||
|
||||
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
||||
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
||||
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...
|
||||
|
||||
@@ -30,13 +31,17 @@ theme change the terminal colors
|
||||
|
||||
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.`
|
||||
|
||||
if (!transcriptEl || !inputEl) {
|
||||
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
|
||||
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.
|
||||
@@ -48,7 +53,9 @@ if (!transcriptEl || !inputEl) {
|
||||
|
||||
function refreshChips(): void {
|
||||
renderChips(computeChips(state, world), (command) => {
|
||||
clearIdleHint()
|
||||
inputEl!.value = command
|
||||
syncCommandLine()
|
||||
if (command.endsWith(' ')) {
|
||||
inputEl!.focus()
|
||||
inputEl!.setSelectionRange(command.length, command.length)
|
||||
@@ -66,6 +73,12 @@ if (!transcriptEl || !inputEl) {
|
||||
inputEl!.classList.toggle('ended', state.endedWith !== null)
|
||||
}
|
||||
|
||||
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[] }[] = []
|
||||
@@ -110,6 +123,23 @@ if (!transcriptEl || !inputEl) {
|
||||
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
||||
}
|
||||
|
||||
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
|
||||
@@ -136,18 +166,58 @@ if (!transcriptEl || !inputEl) {
|
||||
renderAll(lines)
|
||||
}
|
||||
|
||||
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()
|
||||
inputEl.focus()
|
||||
syncCommandLine()
|
||||
scheduleIdleHint()
|
||||
|
||||
inputEl.addEventListener('keydown', (e) => {
|
||||
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 = ''
|
||||
syncCommandLine()
|
||||
if (!raw.trim()) return
|
||||
clearTransientHelp()
|
||||
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.
|
||||
@@ -162,18 +232,7 @@ if (!transcriptEl || !inputEl) {
|
||||
// Engine-level meta-commands handled here so the engine stays pure.
|
||||
const trimmed = raw.trim().toLowerCase()
|
||||
if (trimmed === 'restart') {
|
||||
const confirmed = confirm('Restart? Your progress will be lost.')
|
||||
if (!confirmed) {
|
||||
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
|
||||
return
|
||||
}
|
||||
clearSave()
|
||||
state = initialStateFor(world)
|
||||
transcriptEl.innerHTML = ''
|
||||
renderAll(state.transcript)
|
||||
saveState(state)
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
restart()
|
||||
return
|
||||
}
|
||||
if (trimmed === 'help') {
|
||||
@@ -220,10 +279,32 @@ if (!transcriptEl || !inputEl) {
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('input', syncCommandLine)
|
||||
inputEl.addEventListener('focus', clearIdleHint)
|
||||
inputEl.addEventListener('pointerdown', clearIdleHint)
|
||||
|
||||
inputEl.parentElement?.addEventListener('pointerdown', () => {
|
||||
inputEl.focus()
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
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 = '/'
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('halfstreet-restart', restart)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const STORAGE_KEY = 'halfstreet:theme:v1'
|
||||
const CURSOR_STORAGE_KEY = 'halfstreet:cursor:v1'
|
||||
|
||||
type Theme = 'amber' | 'ansi'
|
||||
type Cursor = 'bar' | 'block' | 'underscore'
|
||||
|
||||
function getStored(): Theme {
|
||||
try {
|
||||
@@ -10,6 +12,15 @@ function getStored(): Theme {
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredCursor(): Cursor {
|
||||
try {
|
||||
const stored = localStorage.getItem(CURSOR_STORAGE_KEY)
|
||||
return stored === 'block' || stored === 'underscore' ? stored : 'bar'
|
||||
} catch {
|
||||
return 'bar'
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(theme: Theme): void {
|
||||
document.documentElement.setAttribute('data-mystery-theme', theme)
|
||||
try {
|
||||
@@ -22,8 +33,46 @@ function setTheme(theme: Theme): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setCursor(cursor: Cursor): void {
|
||||
document.documentElement.setAttribute('data-mystery-cursor', cursor)
|
||||
try {
|
||||
localStorage.setItem(CURSOR_STORAGE_KEY, cursor)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-cursor-choice]')) {
|
||||
btn.setAttribute('aria-pressed', btn.dataset['cursorChoice'] === cursor ? 'true' : 'false')
|
||||
}
|
||||
}
|
||||
|
||||
const initial = getStored()
|
||||
setTheme(initial)
|
||||
setCursor(getStoredCursor())
|
||||
|
||||
const optionsRoot = document.querySelector<HTMLElement>('[data-mystery-options]')
|
||||
const optionsToggle = document.querySelector<HTMLButtonElement>('[data-options-toggle]')
|
||||
const optionsMenu = document.querySelector<HTMLElement>('[data-options-menu]')
|
||||
|
||||
function setOptionsOpen(open: boolean): void {
|
||||
if (!optionsToggle || !optionsMenu) return
|
||||
optionsToggle.setAttribute('aria-expanded', open ? 'true' : 'false')
|
||||
optionsMenu.hidden = !open
|
||||
document.documentElement.toggleAttribute('data-mystery-options-open', open)
|
||||
}
|
||||
|
||||
optionsToggle?.addEventListener('click', () => {
|
||||
setOptionsOpen(optionsToggle.getAttribute('aria-expanded') !== 'true')
|
||||
})
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!optionsRoot || !optionsRoot.contains(event.target as Node)) {
|
||||
setOptionsOpen(false)
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') setOptionsOpen(false)
|
||||
})
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -32,6 +81,18 @@ document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]').forEach((btn
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-cursor-choice]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const next = (btn.dataset['cursorChoice'] as Cursor | undefined) ?? 'bar'
|
||||
setCursor(next)
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-restart-choice]')?.addEventListener('click', () => {
|
||||
setOptionsOpen(false)
|
||||
document.dispatchEvent(new CustomEvent('halfstreet-restart'))
|
||||
})
|
||||
|
||||
// Allow the engine's `theme` meta-command (handled in terminal.ts) to flip
|
||||
// without going through the button by listening for a custom event.
|
||||
document.addEventListener('halfstreet-toggle-theme', () => {
|
||||
|
||||
Reference in New Issue
Block a user