feat(ui): add confirmations and terminal motion
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -46,6 +46,7 @@ export function initialStateFor(world: World): GameState {
|
|||||||
encounterState: {},
|
encounterState: {},
|
||||||
lastNoun: null,
|
lastNoun: null,
|
||||||
pendingDisambiguation: null,
|
pendingDisambiguation: null,
|
||||||
|
pendingConfirmation: null,
|
||||||
transcript: opening,
|
transcript: opening,
|
||||||
endedWith: null,
|
endedWith: null,
|
||||||
}
|
}
|
||||||
@@ -120,7 +121,51 @@ function withEndingCheck(result: DispatchResult, world: World): DispatchResult {
|
|||||||
return { state: updated, appended: [...result.appended, endingLine] }
|
return { state: updated, appended: [...result.appended, endingLine] }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult {
|
const CRITICAL_VERBS = new Set(['attack'])
|
||||||
|
|
||||||
|
function isCriticalCommand(command: ParsedCommand): boolean {
|
||||||
|
if (command.kind !== 'verb-target' && command.kind !== 'verb-target-prep') return false
|
||||||
|
return CRITICAL_VERBS.has(command.verb)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmationPrompt(command: ParsedCommand): string {
|
||||||
|
if (command.kind === 'verb-target') {
|
||||||
|
return `Are you sure you want to ${command.verb} ${command.target.raw}? Type yes to continue, or no to stop.`
|
||||||
|
}
|
||||||
|
if (command.kind === 'verb-target-prep') {
|
||||||
|
return `Are you sure you want to ${command.verb} ${command.target.raw} ${command.preposition} ${command.indirect.raw}? Type yes to continue, or no to stop.`
|
||||||
|
}
|
||||||
|
return 'Are you sure? Type yes to continue, or no to stop.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatch(state: GameState, command: ParsedCommand, world: World, confirmed = false): DispatchResult {
|
||||||
|
if (command.kind === 'confirmation') {
|
||||||
|
const pending = state.pendingConfirmation
|
||||||
|
if (!pending) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'Nothing to confirm.' }])
|
||||||
|
}
|
||||||
|
const cleared: GameState = { ...state, pendingConfirmation: null }
|
||||||
|
if (!command.confirmed) {
|
||||||
|
return narrate(cleared, [{ kind: 'narration', text: 'Cancelled.' }])
|
||||||
|
}
|
||||||
|
return dispatch(cleared, pending.command, world, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.pendingConfirmation) {
|
||||||
|
state = { ...state, pendingConfirmation: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
|
||||||
|
if (state.endedWith) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmed && isCriticalCommand(command)) {
|
||||||
|
const prompt = confirmationPrompt(command)
|
||||||
|
const next: GameState = { ...state, pendingConfirmation: { command, prompt } }
|
||||||
|
return narrate(next, [{ kind: 'narration', text: prompt }])
|
||||||
|
}
|
||||||
|
|
||||||
// Disambiguation reply: re-issue the original verb with the chosen target.
|
// Disambiguation reply: re-issue the original verb with the chosen target.
|
||||||
if (command.kind === 'disambiguation') {
|
if (command.kind === 'disambiguation') {
|
||||||
const pending = state.pendingDisambiguation
|
const pending = state.pendingDisambiguation
|
||||||
@@ -132,14 +177,10 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
cleared,
|
cleared,
|
||||||
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
|
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
|
||||||
world,
|
world,
|
||||||
|
confirmed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
|
|
||||||
if (state.endedWith) {
|
|
||||||
return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.kind === 'unknown') {
|
if (command.kind === 'unknown') {
|
||||||
const text =
|
const text =
|
||||||
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
|
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
|
||||||
|
|||||||
@@ -112,11 +112,25 @@ describe('encounters — phase advancement', () => {
|
|||||||
it('wrong verb costs resolve and surfaces a clue', () => {
|
it('wrong verb costs resolve and surfaces a clue', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
|
const prompt = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
|
||||||
|
expect(prompt.state.pendingConfirmation).toBeDefined()
|
||||||
|
expect(prompt.appended.at(-1)?.text).toContain('Are you sure')
|
||||||
|
const r = dispatch(prompt.state, { kind: 'confirmation', confirmed: true }, world)
|
||||||
expect(r.state.resolveLevel).toBe('shaken')
|
expect(r.state.resolveLevel).toBe('shaken')
|
||||||
expect(r.state.encounterState['revenant']).toBe('shaken')
|
expect(r.state.encounterState['revenant']).toBe('shaken')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('cancels a confirmed attack when the player says no', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
const prompt = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
|
||||||
|
const r = dispatch(prompt.state, { kind: 'confirmation', confirmed: false }, world)
|
||||||
|
expect(r.state.pendingConfirmation).toBeNull()
|
||||||
|
expect(r.state.resolveLevel).toBe('steady')
|
||||||
|
expect(r.state.encounterState['revenant']).toBe('wary')
|
||||||
|
expect(r.appended.at(-1)?.text).toBe('Cancelled.')
|
||||||
|
})
|
||||||
|
|
||||||
it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => {
|
it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
@@ -129,7 +143,8 @@ describe('encounters — phase advancement', () => {
|
|||||||
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
// Force resolve to 'returning' so the next failure retreats.
|
// Force resolve to 'returning' so the next failure retreats.
|
||||||
s = { ...s, resolveLevel: 'returning' }
|
s = { ...s, resolveLevel: 'returning' }
|
||||||
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
|
s = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world).state
|
||||||
|
const r = dispatch(s, { kind: 'confirmation', confirmed: true }, world)
|
||||||
expect(r.state.location).toBe('foyer')
|
expect(r.state.location).toBe('foyer')
|
||||||
expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true)
|
expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ describe('parser — verb-only commands', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('parser — confirmations', () => {
|
||||||
|
it('recognizes yes and no confirmation replies', () => {
|
||||||
|
expect(parse('yes', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: true })
|
||||||
|
expect(parse('y', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: true })
|
||||||
|
expect(parse('no', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps n as the north direction shortcut', () => {
|
||||||
|
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('parser — direction shortcuts', () => {
|
describe('parser — direction shortcuts', () => {
|
||||||
it('maps single-letter directions', () => {
|
it('maps single-letter directions', () => {
|
||||||
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
|||||||
const tokens = tokenize(trimmed)
|
const tokens = tokenize(trimmed)
|
||||||
const head = tokens[0]!
|
const head = tokens[0]!
|
||||||
|
|
||||||
|
if (tokens.length === 1 && ['yes', 'y'].includes(head)) {
|
||||||
|
return { kind: 'confirmation', confirmed: true }
|
||||||
|
}
|
||||||
|
if (tokens.length === 1 && head === 'no') {
|
||||||
|
return { kind: 'confirmation', confirmed: false }
|
||||||
|
}
|
||||||
|
|
||||||
// Meta-commands take precedence (single-word).
|
// Meta-commands take precedence (single-word).
|
||||||
if (META_VERBS[head] && tokens.length === 1) {
|
if (META_VERBS[head] && tokens.length === 1) {
|
||||||
return { kind: 'meta', verb: META_VERBS[head]! }
|
return { kind: 'meta', verb: META_VERBS[head]! }
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe('playthrough — sample world', () => {
|
|||||||
'take lamp',
|
'take lamp',
|
||||||
'e', // hallway → cellar-stair (triggers rat encounter)
|
'e', // hallway → cellar-stair (triggers rat encounter)
|
||||||
'attack rat',
|
'attack rat',
|
||||||
|
'yes',
|
||||||
])
|
])
|
||||||
expect(state.flags['ratGone']).toBe(true)
|
expect(state.flags['ratGone']).toBe(true)
|
||||||
expect(state.location).toBe('cellar-stair')
|
expect(state.location).toBe('cellar-stair')
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const baseState = (overrides: Partial<GameState> = {}): GameState => ({
|
|||||||
encounterState: {},
|
encounterState: {},
|
||||||
lastNoun: null,
|
lastNoun: null,
|
||||||
pendingDisambiguation: null,
|
pendingDisambiguation: null,
|
||||||
|
pendingConfirmation: null,
|
||||||
transcript: [],
|
transcript: [],
|
||||||
endedWith: null,
|
endedWith: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface NounRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ParsedCommand =
|
export type ParsedCommand =
|
||||||
|
| { kind: 'confirmation'; confirmed: boolean }
|
||||||
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' }
|
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' }
|
||||||
| { kind: 'verb-target'; verb: Verb; target: NounRef }
|
| { kind: 'verb-target'; verb: Verb; target: NounRef }
|
||||||
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
|
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
|
||||||
@@ -51,6 +52,11 @@ export interface PendingDisambiguation {
|
|||||||
prompt: string
|
prompt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingConfirmation {
|
||||||
|
command: ParsedCommand
|
||||||
|
prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
schemaVersion: number
|
schemaVersion: number
|
||||||
location: RoomId
|
location: RoomId
|
||||||
@@ -66,6 +72,8 @@ export interface GameState {
|
|||||||
lastNoun: NounRef | null
|
lastNoun: NounRef | null
|
||||||
/** Pending multi-word disambiguation, set when the parser cannot decide. */
|
/** Pending multi-word disambiguation, set when the parser cannot decide. */
|
||||||
pendingDisambiguation: PendingDisambiguation | null
|
pendingDisambiguation: PendingDisambiguation | null
|
||||||
|
/** Pending confirmation for dangerous/game-changing commands. */
|
||||||
|
pendingConfirmation: PendingConfirmation | null
|
||||||
/** Capped at 200 entries; older entries are dropped on append. */
|
/** Capped at 200 entries; older entries are dropped on append. */
|
||||||
transcript: TranscriptLine[]
|
transcript: TranscriptLine[]
|
||||||
/** Set true when the player has reached an ending. UI shows ending screen. */
|
/** Set true when the player has reached an ending. UI shows ending screen. */
|
||||||
|
|||||||
@@ -48,4 +48,19 @@ describe('computeChips — sample world', () => {
|
|||||||
expect(chips.find((c) => c.command === 'wait')).toBeTruthy()
|
expect(chips.find((c) => c.command === 'wait')).toBeTruthy()
|
||||||
expect(chips.find((c) => c.command === 'help')).toBeTruthy()
|
expect(chips.find((c) => c.command === 'help')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows only confirmation chips while a dangerous command is pending', () => {
|
||||||
|
const s = {
|
||||||
|
...initialStateFor(world),
|
||||||
|
pendingConfirmation: {
|
||||||
|
command: { kind: 'verb-target' as const, verb: 'attack' as const, target: { canonical: 'rat', raw: 'rat' } },
|
||||||
|
prompt: 'Are you sure?',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const chips = computeChips(s, world)
|
||||||
|
expect(chips).toEqual([
|
||||||
|
{ kind: 'meta', label: 'YES', command: 'yes', disabled: false },
|
||||||
|
{ kind: 'meta', label: 'NO', command: 'no', disabled: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export function computeChips(state: GameState, world: World): Chip[] {
|
|||||||
const room = world.rooms[state.location]
|
const room = world.rooms[state.location]
|
||||||
if (!room) return out
|
if (!room) return out
|
||||||
|
|
||||||
|
if (state.pendingConfirmation) {
|
||||||
|
return [
|
||||||
|
{ kind: 'meta', label: 'YES', command: 'yes', disabled: false },
|
||||||
|
{ kind: 'meta', label: 'NO', command: 'no', disabled: false },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// Direction chips: enabled if exit exists, dimmed otherwise.
|
// Direction chips: enabled if exit exists, dimmed otherwise.
|
||||||
const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
|
const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
|
||||||
for (const d of dirs) {
|
for (const d of dirs) {
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-anchor: none;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scrollbar-color: var(--m-dim) var(--m-bg);
|
scrollbar-color: var(--m-dim) var(--m-bg);
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -239,6 +240,16 @@ body {
|
|||||||
margin-top: 0.6em;
|
margin-top: 0.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .room-title {
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .room-scroll-spacer {
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mystery-transcript .ascii-art {
|
.mystery-transcript .ascii-art {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|||||||
+134
-19
@@ -44,6 +44,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
let historyIndex: number | null = null
|
let historyIndex: number | null = null
|
||||||
let historyDraft = ''
|
let historyDraft = ''
|
||||||
let idleHintTimer: number | null = null
|
let idleHintTimer: number | null = null
|
||||||
|
let renderQueue: Promise<void> = Promise.resolve()
|
||||||
|
let renderGeneration = 0
|
||||||
|
let roomScrollSpacer: HTMLDivElement | null = null
|
||||||
|
|
||||||
|
const TYPE_INTERVAL_MS = 8
|
||||||
|
const TYPE_CHARS_PER_TICK = 3
|
||||||
|
const ROOM_SCROLL_MS = 180
|
||||||
|
|
||||||
const syncLightMeter = (): void => {
|
const syncLightMeter = (): void => {
|
||||||
if (!lightMeterEl) return
|
if (!lightMeterEl) return
|
||||||
@@ -152,18 +159,123 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderAll = (lines: TranscriptLine[]): void => {
|
const wait = (ms: number): Promise<void> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAsciiArtLine = (line: TranscriptLine): boolean =>
|
||||||
|
line.kind === 'system' && line.text.includes('|_| |_|')
|
||||||
|
|
||||||
|
const isRoomTitleLine = (line: TranscriptLine): boolean => {
|
||||||
|
if (line.kind !== 'system') return false
|
||||||
|
const trimmed = line.text.trim()
|
||||||
|
return /^\[\s*.+\s*\]$/.test(trimmed) && !trimmed.includes('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLine = async (el: HTMLDivElement, text: string): Promise<void> => {
|
||||||
|
el.textContent = ''
|
||||||
|
for (let i = TYPE_CHARS_PER_TICK; i < text.length; i += TYPE_CHARS_PER_TICK) {
|
||||||
|
el.textContent = text.slice(0, i)
|
||||||
|
await wait(TYPE_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
el.textContent = text
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToTopOf = async (el: HTMLElement): Promise<void> => {
|
||||||
if (!transcriptEl) return
|
if (!transcriptEl) return
|
||||||
|
transcriptEl.scrollTo({ top: Math.max(0, el.offsetTop), behavior: 'smooth' })
|
||||||
|
await wait(ROOM_SCROLL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRoomScrollSpacer = (anchor?: HTMLElement): void => {
|
||||||
|
if (!transcriptEl) return
|
||||||
|
if (!roomScrollSpacer) {
|
||||||
|
roomScrollSpacer = document.createElement('div')
|
||||||
|
roomScrollSpacer.className = 'room-scroll-spacer'
|
||||||
|
roomScrollSpacer.setAttribute('aria-hidden', 'true')
|
||||||
|
}
|
||||||
|
const spacerHeight = Math.max(0, transcriptEl.clientHeight - (anchor?.offsetHeight ?? 0))
|
||||||
|
roomScrollSpacer.style.height = `${spacerHeight}px`
|
||||||
|
if (roomScrollSpacer.parentElement !== transcriptEl) {
|
||||||
|
transcriptEl.appendChild(roomScrollSpacer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToContentBottom = (): void => {
|
||||||
|
if (!transcriptEl) return
|
||||||
|
updateRoomScrollSpacer()
|
||||||
|
const contentBottom = roomScrollSpacer?.offsetTop ?? transcriptEl.scrollHeight
|
||||||
|
transcriptEl.scrollTop = Math.max(0, contentBottom - transcriptEl.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendTranscriptElement = (el: HTMLDivElement): void => {
|
||||||
|
if (!transcriptEl) return
|
||||||
|
updateRoomScrollSpacer()
|
||||||
|
if (roomScrollSpacer?.parentElement === transcriptEl) {
|
||||||
|
transcriptEl.insertBefore(el, roomScrollSpacer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transcriptEl.appendChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderLines = async (
|
||||||
|
lines: TranscriptLine[],
|
||||||
|
animate: boolean,
|
||||||
|
shouldScroll: boolean,
|
||||||
|
generation: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!transcriptEl) return
|
||||||
|
const roomTitleInBatch = shouldScroll && animate && lines.some(isRoomTitleLine)
|
||||||
|
let roomTitleEl: HTMLDivElement | null = null
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
if (generation !== renderGeneration) return
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.className = line.kind
|
el.className = line.kind
|
||||||
if (line.kind === 'system' && line.text.includes('|_| |_|')) {
|
const asciiArt = isAsciiArtLine(line)
|
||||||
|
const roomTitle = roomTitleInBatch && isRoomTitleLine(line)
|
||||||
|
|
||||||
|
if (asciiArt) {
|
||||||
el.classList.add('ascii-art')
|
el.classList.add('ascii-art')
|
||||||
}
|
}
|
||||||
el.textContent = line.text
|
if (isRoomTitleLine(line)) {
|
||||||
transcriptEl.appendChild(el)
|
el.classList.add('room-title')
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldType = animate && line.kind !== 'player' && !asciiArt && !roomTitle
|
||||||
|
el.textContent = shouldType ? '' : line.text
|
||||||
|
appendTranscriptElement(el)
|
||||||
|
|
||||||
|
if (roomTitle) {
|
||||||
|
roomTitleEl = el
|
||||||
|
updateRoomScrollSpacer(el)
|
||||||
|
await scrollToTopOf(el)
|
||||||
|
if (generation !== renderGeneration) return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldType) {
|
||||||
|
await typeLine(el, line.text)
|
||||||
|
if (generation !== renderGeneration) return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldScroll && !roomTitleInBatch) {
|
||||||
|
scrollToContentBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
|
||||||
|
if (shouldScroll && !roomTitleEl) {
|
||||||
|
scrollToContentBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAll = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
|
||||||
|
const animate = options.animate ?? true
|
||||||
|
const shouldScroll = options.scroll ?? true
|
||||||
|
const generation = renderGeneration
|
||||||
|
renderQueue = renderQueue.then(() => renderLines(lines, animate, shouldScroll, generation)).catch((err) => {
|
||||||
|
console.error('[halfstreet] render error', err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearIdleHint = (): void => {
|
const clearIdleHint = (): void => {
|
||||||
@@ -211,9 +323,9 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
text.className = 'mystery-help-body'
|
text.className = 'mystery-help-body'
|
||||||
text.textContent = HELP_TEXT
|
text.textContent = HELP_TEXT
|
||||||
el.append(close, text)
|
el.append(close, text)
|
||||||
transcriptEl.appendChild(el)
|
appendTranscriptElement(el)
|
||||||
transientHelpEl = el
|
transientHelpEl = el
|
||||||
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
scrollToContentBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('pointerdown', (e) => {
|
document.addEventListener('pointerdown', (e) => {
|
||||||
@@ -234,30 +346,32 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
// notices). Pushes into state.transcript so they survive reload, then renders.
|
// notices). Pushes into state.transcript so they survive reload, then renders.
|
||||||
// Engine-originated lines (from dispatch) are already in state.transcript;
|
// Engine-originated lines (from dispatch) are already in state.transcript;
|
||||||
// those use renderAll directly.
|
// those use renderAll directly.
|
||||||
const appendLines = (lines: TranscriptLine[]): void => {
|
const appendLines = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
|
||||||
state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) }
|
state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) }
|
||||||
renderAll(lines)
|
renderAll(lines, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restart = (): void => {
|
const restart = (): void => {
|
||||||
const confirmed = confirm('Restart? Your progress will be lost.')
|
const confirmed = confirm('Restart? Your progress will be lost.')
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
|
appendLines([{ kind: 'system', text: '(restart cancelled)' }], { scroll: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearSave()
|
clearSave()
|
||||||
state = initialStateFor(world)
|
state = initialStateFor(world)
|
||||||
|
renderGeneration += 1
|
||||||
|
renderQueue = Promise.resolve()
|
||||||
transcriptEl.innerHTML = ''
|
transcriptEl.innerHTML = ''
|
||||||
inputEl.value = ''
|
inputEl.value = ''
|
||||||
syncCommandLine()
|
syncCommandLine()
|
||||||
renderAll(state.transcript)
|
renderAll(state.transcript, { animate: false })
|
||||||
saveState(state)
|
saveState(state)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
syncLightMeter()
|
syncLightMeter()
|
||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAll(state.transcript)
|
renderAll(state.transcript, { animate: false })
|
||||||
refreshChips()
|
refreshChips()
|
||||||
syncLightMeter()
|
syncLightMeter()
|
||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
@@ -293,13 +407,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
commandHistory = [...commandHistory, raw].slice(-50)
|
commandHistory = [...commandHistory, raw].slice(-50)
|
||||||
historyIndex = null
|
historyIndex = null
|
||||||
historyDraft = ''
|
historyDraft = ''
|
||||||
appendLines([{ kind: 'player', text: raw }])
|
appendLines([{ kind: 'player', text: raw }], { scroll: false })
|
||||||
|
|
||||||
// Once the game has ended, only restart and undo are allowed.
|
// Once the game has ended, only restart and undo are allowed.
|
||||||
if (state.endedWith !== null) {
|
if (state.endedWith !== null) {
|
||||||
const lower = raw.trim().toLowerCase()
|
const lower = raw.trim().toLowerCase()
|
||||||
if (lower !== 'restart' && lower !== 'undo') {
|
if (lower !== 'restart' && lower !== 'undo') {
|
||||||
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }])
|
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }], { scroll: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,13 +432,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
if (lastState) {
|
if (lastState) {
|
||||||
state = lastState
|
state = lastState
|
||||||
lastState = null
|
lastState = null
|
||||||
appendLines([{ kind: 'system', text: '(undone)' }])
|
appendLines([{ kind: 'system', text: '(undone)' }], { scroll: false })
|
||||||
saveState(state)
|
saveState(state)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
syncLightMeter()
|
syncLightMeter()
|
||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
} else {
|
} else {
|
||||||
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
appendLines([{ kind: 'system', text: 'There is no further back.' }], { scroll: false })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -339,11 +453,12 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
const ctx = buildParserContext(state)
|
const ctx = buildParserContext(state)
|
||||||
const command = parse(raw, ctx)
|
const command = parse(raw, ctx)
|
||||||
lastState = state
|
lastState = state
|
||||||
|
const previousLocation = state.location
|
||||||
const result = dispatch(state, command, world)
|
const result = dispatch(state, command, world)
|
||||||
state = result.state
|
state = result.state
|
||||||
renderAll(result.appended) // dispatch already pushed these into state.transcript
|
const shouldScrollToRoom = command.kind === 'go' && state.location !== previousLocation
|
||||||
|
renderAll(result.appended, { scroll: shouldScrollToRoom }) // dispatch already pushed these into state.transcript
|
||||||
saveState(state)
|
saveState(state)
|
||||||
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
|
||||||
if (raw.trim().toLowerCase() === 'theme') {
|
if (raw.trim().toLowerCase() === 'theme') {
|
||||||
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
||||||
}
|
}
|
||||||
@@ -352,7 +467,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[halfstreet] dispatch error', err)
|
console.error('[halfstreet] dispatch error', err)
|
||||||
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
|
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }], { scroll: false })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -24,7 +24,7 @@
|
|||||||
- [x] If the user says "light match" or "light match" the response should be "use match with what?"
|
- [x] If the user says "light match" or "light match" the response should be "use match with what?"
|
||||||
- [x] If the user says "use match with letter" they should burn the letter.
|
- [x] If the user says "use match with letter" they should burn the letter.
|
||||||
- [x] There should be a lighter in the smoking room that allows unlimited lighting.
|
- [x] There should be a lighter in the smoking room that allows unlimited lighting.
|
||||||
- [ ] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
|
- [x] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
|
||||||
- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or encounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
|
- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or encounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
|
||||||
- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
|
- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
|
||||||
- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
|
- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
|
||||||
@@ -44,4 +44,5 @@
|
|||||||
- [ ] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before.
|
- [ ] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before.
|
||||||
- [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later.
|
- [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later.
|
||||||
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
|
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
|
||||||
|
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
|
||||||
|
- [x] FEATURE: Whenever you change rooms, scroll the text so the name of the room you're in is at the top. Users can scroll up to see the history. This should be an effect where the old text slides up to make room for the new text, and this should happen before the "typed" effect.
|
||||||
|
|||||||
Reference in New Issue
Block a user