(null)
/** The quest news of the part up, or null. */
const news = computed(() => arrival.part?.news ?? null)
@@ -118,7 +119,9 @@ function room(): number {
const style = getComputedStyle(stage)
const padding =
(parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0)
- const trim = head.value && props.sideways ? SIDEWAYS_TITLE_TRIM : 0
+ // Only a news head gives the 2 px back on a sideways phone; the room's heading does not.
+ const trim =
+ head.value && piece.value && props.sideways ? SIDEWAYS_TITLE_TRIM : 0
const gaps = (head.value ? 2 * CARD_GAP : CARD_GAP) - trim
return (
stage.clientHeight -
@@ -366,6 +369,21 @@ onBeforeUnmount(() => {
>
+
{
.lq-arrival-stage .lq-news-kicker.lq-news-kicker-spell {
color: var(--lq-spell);
}
+/* A first visit's heading: "You are in" and the room, as the room's title has them. */
+.lq-room-head {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+}
+.lq-room-kicker {
+ margin: 0;
+ font-family: var(--lq-font-ui);
+ font-size: 12px;
+ letter-spacing: 0.3em;
+ text-transform: uppercase;
+}
+.lq-room-head-name {
+ margin: 0;
+ font-family: var(--lq-font-display);
+ font-size: calc(44px * var(--lq-display-scale, 1));
+ font-weight: 500;
+ line-height: 1.1;
+}
+.lq-phone-arrival .lq-room-head-name,
+.lq-sideways-arrival .lq-room-head-name {
+ font-size: calc(30px * var(--lq-display-scale, 1));
+}
+.lq-room-head-rule {
+ width: 120px;
+ height: 1px;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgb(255 230 180 / 0.8),
+ transparent
+ );
+}
.lq-news-name {
margin: 0;
font-family: var(--lq-font-display);
diff --git a/web/src/components/lantern/LanternLayout.phone.test.ts b/web/src/components/lantern/LanternLayout.phone.test.ts
index 195fd6a62..e535245d7 100644
--- a/web/src/components/lantern/LanternLayout.phone.test.ts
+++ b/web/src/components/lantern/LanternLayout.phone.test.ts
@@ -46,7 +46,8 @@ describe('LanternLayout: the stage on a phone', () => {
it('plays the room on its picture, with the plate and a folded bar', () => {
expect(w.find('.lq-stage-picture').exists()).toBe(true)
- expect(w.get('.lq-plate').text()).toContain('You are in')
+ expect(w.get('.lq-plate h2').text()).toBe(useGameStore().roomName)
+ expect(w.get('.lq-plate').text()).not.toContain('You are in')
expect(w.find('.lq-dock').exists()).toBe(true)
expect(w.get('.lq-sheet').isVisible()).toBe(false)
})
@@ -115,12 +116,9 @@ describe('LanternLayout: the stage on a phone', () => {
expect(w.find('.lq-story-drawer').exists()).toBe(false)
})
- it('reaches the chips before "⟲ Story so far" with Tab', () => {
- const plate = w.get('.lq-plate').element
- const order = [...plate.querySelectorAll('button')].map((b) =>
- b.classList.contains('lq-story-so-far') ? 'story' : 'chip',
- )
- expect(order.at(-1)).toBe('story')
+ it('has "⟲ Story so far" as the only button on the plate: no one and nothing is listed', () => {
+ const buttons = w.get('.lq-plate').findAll('button')
+ expect(buttons.map((b) => b.text())).toEqual(['⟲ Story so far'])
})
it('keeps the stage as it was left when another tab is visited', async () => {
@@ -131,112 +129,6 @@ describe('LanternLayout: the stage on a phone', () => {
})
})
- describe('upright, with little room for a conversation', () => {
- const WORDS =
- '[character_name]Grandmother[/character_name]\n[dialogue]There you are.[/dialogue]'
-
- /** The column's height changes: the observers the layout set are told. */
- const observers: (() => void)[] = []
-
- beforeEach(async () => {
- observers.length = 0
- vi.stubGlobal(
- 'ResizeObserver',
- class {
- constructor(callback: () => void) {
- observers.push(callback)
- }
- observe(): void {}
- unobserve(): void {}
- disconnect(): void {}
- },
- )
- await mountOn('upright')
- })
-
- /** A turn answered with Grandmother's words, addressed to her. */
- async function grandmotherSpeaks(): Promise {
- const game = useGameStore()
- game.turns.push({
- id: (game.turns.at(-1)?.id ?? 0) + 1,
- command: 'talk to grandmother',
- text: WORDS,
- room: 'Cottage',
- description: null,
- gained: [],
- lost: [],
- arrival: null,
- addressed: 'Grandmother',
- })
- await flushPromises()
- }
-
- /** The column has `height` px of room between the plate and the Hide. */
- async function roomIs(height: number): Promise {
- Object.defineProperty(w.get('.lq-story-wrap').element, 'clientHeight', {
- configurable: true,
- value: height,
- })
- observers.forEach((notify) => notify())
- await flushPromises()
- }
-
- const plate = () => w.get('.lq-phone-plate')
-
- it('stays open when the conversation has room', async () => {
- await grandmotherSpeaks()
- await roomIs(226)
- expect(plate().classes()).not.toContain('lq-plate-folded')
- })
-
- it('folds when the room is too small, and stays folded as the room grows and lines come', async () => {
- await grandmotherSpeaks()
- await roomIs(58)
- expect(plate().classes()).toContain('lq-plate-folded')
- await roomIs(140)
- await grandmotherSpeaks()
- await roomIs(58)
- expect(plate().classes()).toContain('lq-plate-folded')
- })
-
- it('does not fold without a conversation on the picture', async () => {
- await roomIs(58)
- expect(plate().classes()).not.toContain('lq-plate-folded')
- })
-
- it('opens again on Escape, which hides the words', async () => {
- await grandmotherSpeaks()
- await roomIs(58)
- useGameStore().dismissTitle()
- useGameStore().showModal = false
- document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
- await flushPromises()
- expect(plate().classes()).not.toContain('lq-plate-folded')
- })
-
- it('opens again when the narration is hidden, and does not fold again while hidden', async () => {
- await grandmotherSpeaks()
- await roomIs(58)
- await w.get('.lq-words-hide').trigger('click')
- await flushPromises()
- expect(plate().classes()).not.toContain('lq-plate-folded')
- await roomIs(58)
- expect(plate().classes()).not.toContain('lq-plate-folded')
- })
-
- it('keeps the names in the same tab order when folded', async () => {
- await grandmotherSpeaks()
- const before = plate()
- .findAll('button')
- .map((b) => b.text())
- await roomIs(58)
- const after = plate()
- .findAll('button')
- .map((b) => b.text())
- expect(after).toEqual(before)
- })
- })
-
describe('a phone held upright', () => {
beforeEach(() => mountOn('turn'))
diff --git a/web/src/components/lantern/LanternLayout.scene.test.ts b/web/src/components/lantern/LanternLayout.scene.test.ts
index c29b5b654..dbcd31d1e 100644
--- a/web/src/components/lantern/LanternLayout.scene.test.ts
+++ b/web/src/components/lantern/LanternLayout.scene.test.ts
@@ -287,6 +287,21 @@ describe('LanternLayout: change of scene', () => {
expect(useArrivalStore().active).toBe(true)
})
+ /**
+ * Press a button in a held panel (since the room card lists nothing, the
+ * stage has no such button until the map opens): one stands in the stage.
+ */
+ async function pressHeldPanel(): Promise {
+ const panel = document.createElement('div')
+ panel.setAttribute('data-panel', '')
+ const button = document.createElement('button')
+ panel.append(button)
+ w.get('.lq-stage').element.append(panel)
+ button.click()
+ panel.remove()
+ await w.vm.$nextTick()
+ }
+
/** Submit `talk to voice` and return the turn and the index of its waking paragraph. */
async function answerVoice(
game: ReturnType,
@@ -313,7 +328,7 @@ describe('LanternLayout: change of scene', () => {
await answerVoice(game)
// Over the dream, the look is still on the left.
expect(looked()).toBe(true)
- await w.get('[data-panel] button').trigger('click')
+ await pressHeldPanel()
await w.vm.$nextTick()
expect(looked()).toBe(false)
})
@@ -342,7 +357,7 @@ describe('LanternLayout: change of scene', () => {
const narration = useNarrationStore()
const turn = await answerVoice(game)
expect(game.panelsHeld).toBe(true)
- await w.get('[data-panel] button').trigger('click')
+ await pressHeldPanel()
expect(scene.turn).toBeNull()
expect(game.musicRoomKey).toBe('EliorsCottage')
expect(game.roomName).toBe("Elior's Cottage")
@@ -387,7 +402,7 @@ describe('LanternLayout: change of scene', () => {
expect(story()).not.toContain('You answer it')
noEmptyCard()
// A press brings the cottage up at once.
- await w.get('[data-panel] button').trigger('click')
+ await pressHeldPanel()
await w.vm.$nextTick()
expect(scene.turn).toBeNull()
noEmptyCard()
diff --git a/web/src/components/lantern/LanternLayout.test.ts b/web/src/components/lantern/LanternLayout.test.ts
index c5e0942fa..d31ab088b 100644
--- a/web/src/components/lantern/LanternLayout.test.ts
+++ b/web/src/components/lantern/LanternLayout.test.ts
@@ -568,7 +568,7 @@ describe('LanternLayout', () => {
})
})
- it('opens the menu from a Here chip in the laptop room card', async () => {
+ it('lists no one and nothing in the laptop room card', async () => {
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({
@@ -579,25 +579,12 @@ describe('LanternLayout', () => {
)
wrapper = mount(LanternLayout, { attachTo: document.body })
const game = useGameStore()
- game.acceptInput = true
game.items = ["Elior's Journal"]
+ game.characters = ['grandmother']
await flushPromises()
- const submit = vi.spyOn(game, 'submitCommand').mockImplementation(() => {})
-
- await wrapper
- .get('[aria-label="Current location"] button.lq-here-item')
- .trigger('click')
- const items = wrapper.findAll('[role="menuitem"]')
- expect(items.map((m) => m.text())).toEqual([
- 'Take',
- 'Use',
- 'Open',
- 'Close',
- 'Examine',
- ])
- await items[2].trigger('click')
-
- expect(submit).toHaveBeenCalledWith("open elior's journal")
+ const plate = wrapper.get('[aria-label="Current location"]')
+ expect(plate.findAll('button')).toHaveLength(0)
+ expect(plate.text()).not.toContain('Journal')
vi.unstubAllGlobals()
})
diff --git a/web/src/components/lantern/LanternLayout.title.test.ts b/web/src/components/lantern/LanternLayout.title.test.ts
new file mode 100644
index 000000000..4347df2f0
--- /dev/null
+++ b/web/src/components/lantern/LanternLayout.title.test.ts
@@ -0,0 +1,172 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import LanternLayout from './LanternLayout.vue'
+import { useGameStore } from '@/stores/useGameStore'
+import { ROOM_TITLE_MS, useRoomTitleStore } from '@/stores/useRoomTitleStore'
+import { useArrivalStore } from '@/stores/useArrivalStore'
+import { startedHoldStore } from '@/testing/holdGame'
+import { setPictureProbe } from '@/lantern/roomPicture'
+import { SHORT_QUERY } from '@/composables/useMediaQuery'
+
+/**
+ * The room's title on the picture stage: "You are in" and the name over the
+ * middle of the picture while it is up, the corner's name away meanwhile, and
+ * any key or press settling it into the corner.
+ */
+describe('LanternLayout: the room title', () => {
+ let w: VueWrapper
+
+ /** Mount a started game on the laptop stage (or the text page, `wide` false and short). */
+ async function stage(wide = true): Promise {
+ setActivePinia(createPinia())
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn((query: string) => ({
+ matches: wide ? query.includes('min-width') : query === SHORT_QUERY,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ )
+ const game = await startedHoldStore()
+ game.showTitle = false
+ w = mount(LanternLayout, { attachTo: document.body })
+ await w.vm.$nextTick()
+ game.musicRoomKey = 'Hall'
+ await w.vm.$nextTick()
+ await w.vm.$nextTick()
+ if (wide) await w.get('img.lq-loading').trigger('load')
+ useRoomTitleStore().settle()
+ await w.vm.$nextTick()
+ }
+
+ /** Put the title up for the room the player is in. */
+ async function greet(): Promise {
+ useRoomTitleStore().greet(useGameStore().roomName)
+ await w.vm.$nextTick()
+ }
+
+ const title = () => w.find('[data-testid="room-title"]')
+ const plate = () => w.get('.lq-stage-plate')
+
+ beforeEach(() => vi.useFakeTimers())
+ afterEach(() => {
+ w.unmount()
+ setPictureProbe(null)
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+ })
+
+ it('tells "You are in" and the room over the picture, the corner name away meanwhile', async () => {
+ await stage()
+ expect(title().exists()).toBe(false)
+ expect(plate().classes()).not.toContain('lq-plate-away')
+ await greet()
+ expect(title().text()).toContain('You are in')
+ expect(title().get('.lq-room-title-name').text()).toBe('Hall')
+ expect(title().attributes('aria-hidden')).toBe('true')
+ expect(plate().classes()).toContain('lq-plate-away')
+ expect(w.get('.lq-main').classes()).toContain('lq-titled')
+ })
+
+ it('settles into the corner after its time', async () => {
+ await stage()
+ await greet()
+ vi.advanceTimersByTime(ROOM_TITLE_MS)
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBeNull()
+ expect(plate().classes()).not.toContain('lq-plate-away')
+ expect(w.get('.lq-main').classes()).not.toContain('lq-titled')
+ })
+
+ it('settles at once on any key', async () => {
+ await stage()
+ await greet()
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBeNull()
+ })
+
+ it('settles at once on a press anywhere, which still does what it does', async () => {
+ await stage()
+ await greet()
+ const press = vi.fn()
+ const button = w.get('.lq-stage-buttons button').element
+ button.addEventListener('pointerdown', press)
+ button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBeNull()
+ expect(press).toHaveBeenCalled()
+ })
+
+ it('keeps the corner name away while an arrival is told on the picture', async () => {
+ await stage()
+ const arrival = useArrivalStore()
+ arrival.show(
+ useGameStore().turns.at(-1)!.id,
+ [{ kind: 'description', lines: [], voiced: false }],
+ false,
+ )
+ await w.vm.$nextTick()
+ expect(plate().classes()).toContain('lq-plate-away')
+ arrival.clear()
+ await w.vm.$nextTick()
+ expect(plate().classes()).not.toContain('lq-plate-away')
+ })
+
+ it('greets the room when a game is loaded in it, once its picture is up', async () => {
+ await stage()
+ const game = useGameStore()
+ // A loaded story is not narrated again (as restoreStory leaves it).
+ game.turns = game.turns.map((turn) => ({ ...turn, narrate: undefined }))
+ game.loadNumber++
+ await w.vm.$nextTick()
+ await w.vm.$nextTick()
+ const loading = w.find('img.lq-loading')
+ if (loading.exists()) await loading.trigger('load')
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBe('Hall')
+ expect(title().text()).toContain('Hall')
+ })
+
+ it('greets a room loaded from another only once its own picture is up, never over the old one', async () => {
+ await stage()
+ const game = useGameStore()
+ game.turns = game.turns.map((turn) => ({ ...turn, narrate: undefined }))
+ game.musicRoomKey = 'Garden'
+ game.roomName = 'Garden'
+ game.loadNumber++
+ await w.vm.$nextTick()
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBeNull()
+ await w.get('img.lq-loading').trigger('load')
+ await w.vm.$nextTick()
+ expect(useRoomTitleStore().name).toBe('Garden')
+ })
+
+ it('keeps the corner name while the picture tells no room (news, what happens)', async () => {
+ await stage()
+ useArrivalStore().show(
+ useGameStore().turns.at(-1)!.id,
+ [
+ {
+ kind: 'events',
+ lines: [{ unit: 0, markup: 'A bell.' }],
+ voiced: false,
+ },
+ ],
+ false,
+ )
+ await w.vm.$nextTick()
+ expect(useArrivalStore().active).toBe(true)
+ expect(plate().classes()).not.toContain('lq-plate-away')
+ })
+
+ it('has no title on the text page', async () => {
+ await stage(false)
+ expect(useRoomTitleStore().staged).toBe(false)
+ expect(title().exists()).toBe(false)
+ })
+})
diff --git a/web/src/components/lantern/LanternLayout.vue b/web/src/components/lantern/LanternLayout.vue
index 7c8e1dd5a..02791aec6 100644
--- a/web/src/components/lantern/LanternLayout.vue
+++ b/web/src/components/lantern/LanternLayout.vue
@@ -6,6 +6,7 @@ import { useSettingsStore } from '@/stores/useSettingsStore'
import { useNarrationStore } from '@/stores/useNarrationStore'
import { useStageSpeechStore } from '@/stores/useStageSpeechStore'
import { useArrivalStore } from '@/stores/useArrivalStore'
+import { useRoomTitleStore } from '@/stores/useRoomTitleStore'
import { useSceneStore } from '@/stores/useSceneStore'
import { useAreaNamesStore } from '@/stores/useAreaNamesStore'
import { useFlightStore } from '@/stores/useFlightStore'
@@ -76,6 +77,7 @@ import SaveLoadDialog from './SaveLoadDialog.vue'
import SettingsDialog from './SettingsDialog.vue'
import LanternHeader from './LanternHeader.vue'
import RoomPlate from './RoomPlate.vue'
+import RoomTitle from './RoomTitle.vue'
import SaveToast from './SaveToast.vue'
import SectionNav, { type Section } from './SectionNav.vue'
import StageButtons from './StageButtons.vue'
@@ -94,8 +96,8 @@ import type { SaveSlot } from '@/types/bridge'
/**
* Shell of the Lantern UI (the only UI; old `?ui=` links open it too): header, story (one
* StoryTurn per turn) and the action composer. On laptops (≥1024 × 560) the
- * room is played on its picture, which fills the play area: a plate (room
- * and who and what is here) top-left, the buttons Bag, Spells, Quests, Story
+ * room is played on its picture, which fills the play area: the room's name
+ * top-left (`RoomPlate`; who and what is here are marked on the picture), the buttons Bag, Spells, Quests, Story
* so far and Map top-right (the journal and the story open as a drawer from
* the right, the map beneath its button), the narrator's words up the left
* side and the actions as one row along the bottom.
@@ -171,6 +173,7 @@ const narration = useNarrationStore()
const speech = useStageSpeechStore()
const arrival = useArrivalStore()
const scene = useSceneStore()
+const roomTitle = useRoomTitleStore()
const names = useAreaNamesStore()
const belt = useBeltStore()
/** The marks and names layer, which knows where the ways out are. */
@@ -276,12 +279,37 @@ const stageKey = computed(
() => `${game.gameNumber}-${game.loadNumber}-${game.musicActName}`,
)
watch(stageKey, () => {
+ roomTitle.reset()
drawerTab.value = null
speech.clear()
scene.reset()
clearArrival()
})
+/** The room's title is told only where the room is played on its picture. */
+watch(
+ () => staged.value,
+ (onStage) => {
+ roomTitle.staged = onStage
+ },
+ { immediate: true },
+)
+/** Whether the room's name in the corner steps away: its title, or a first visit's arrival, is told over the picture (news alone leaves it). */
+const plateAway = computed(() => roomTitle.name !== null || arrival.tellsRoom)
+/** Any key or press settles the room's title into the corner at once; the key or press still does what it does. */
+function settleRoomTitle(): void {
+ if (roomTitle.name !== null) roomTitle.settle()
+}
+onMounted(() => {
+ window.addEventListener('keydown', settleRoomTitle, true)
+ window.addEventListener('pointerdown', settleRoomTitle, true)
+})
+onUnmounted(() => {
+ window.removeEventListener('keydown', settleRoomTitle, true)
+ window.removeEventListener('pointerdown', settleRoomTitle, true)
+ roomTitle.settle()
+})
+
/** End the arrival on the picture at once (a narrator still telling it stops, see `useArrivalStore.clear`). */
function clearArrival(): void {
arrival.clear()
@@ -1060,49 +1088,6 @@ onMounted(() => {
}
})
-/** The least height (px) the column needs to show the newest line's speaker and first line whole. */
-const MIN_SPEECH_ROOM = 120
-/**
- * The phone's room plate is folded to one row. It folds when a conversation is
- * on the picture under an open column of words and the column's room
- * (its height) is under `MIN_SPEECH_ROOM`; then it stays
- * folded, so it does not fold and open line by line, until the conversation
- * is cleared or the narration is hidden. Nothing is saved.
- */
-const plateFolded = ref(false)
-
-/** The column's room as last measured (px). */
-const speechRoom = ref(Number.POSITIVE_INFINITY)
-let columnResize: ResizeObserver | undefined
-watch(
- paperEl,
- (el) => {
- columnResize?.disconnect()
- if (!el || typeof ResizeObserver === 'undefined') return
- columnResize = new ResizeObserver(() => {
- speechRoom.value = el.clientHeight
- })
- columnResize.observe(el)
- },
- { flush: 'post' },
-)
-onUnmounted(() => columnResize?.disconnect())
-
-watch(
- () =>
- [
- speech.visible.length > 0,
- paper.visible.value && !paperAway.value,
- phoneStage.value && section.value === 'story',
- speechRoom.value < MIN_SPEECH_ROOM,
- ] as const,
- ([talking, paperOpen, onPicture, cramped]) => {
- if (!talking || !paperOpen || !onPicture) plateFolded.value = false
- else if (cramped) plateFolded.value = true
- },
- { flush: 'post' },
-)
-
/** The laptop's room plate, under which the narration cards stop. */
const widePlate = ref | null>(null)
/** Where the cards' column ends at the top: the plate's foot and a gap (px from the stage's top). */
@@ -2086,6 +2071,7 @@ watch(
'lq-staged': staged,
'lq-phone': phoneStage,
'lq-sideways': sideways,
+ 'lq-titled': roomTitle.name !== null,
}"
:style="{
...(phoneStage ? { '--lq-plate-h': `${plateHeight}px` } : {}),
@@ -2131,8 +2117,9 @@ watch(
ref="widePlate"
class="lq-stage-plate"
:compact="sideways"
+ :bare="!pictured"
+ :away="plateAway"
data-panel
- @click="onNameClick"
/>
+
+
{
game.items = ['old notice', 'bucket']
})
- it('says "You are in" over the room name', () => {
+ it('names the room alone, as the current location', () => {
const w = mount(RoomPlate)
- expect(w.text()).toContain('You are in')
expect(w.get('h2').text()).toBe('Village Square')
+ expect(w.attributes('aria-label')).toBe('Current location')
+ expect(w.text()).not.toContain('You are in')
})
- it('lists who and what is here, characters blue and items green', () => {
- const chips = mount(RoomPlate).findAll('.lq-here-chip')
- expect(chips.map((c) => c.text())).toEqual([
- 'Villager',
- 'Old Notice',
- 'Bucket',
- ])
- expect(chips[0].classes()).toContain('lq-here-character')
- expect(chips[1].classes()).toContain('lq-here-item')
- })
-
- it('carries what the name menu needs on each chip', () => {
- const chip = mount(RoomPlate).get('.lq-here-chip')
- expect(chip.attributes('data-name-kind')).toBe('character')
- expect(chip.attributes('data-name')).toBe('villager')
- expect(chip.attributes('aria-haspopup')).toBe('menu')
- })
-
- it('keeps chips of one name apart', () => {
- useGameStore().items = ['lantern', 'lantern', 'lantern']
- useGameStore().characters = []
- expect(mount(RoomPlate).findAll('.lq-here-chip')).toHaveLength(3)
+ it('lists no one and nothing that is here: the picture marks them', () => {
+ const w = mount(RoomPlate)
+ expect(w.find('.lq-here').exists()).toBe(false)
+ expect(w.find('[data-name-kind]').exists()).toBe(false)
+ expect(w.text()).not.toContain('Villager')
})
- it('shows only the name when nothing is here', () => {
- const game = useGameStore()
- game.characters = []
- game.items = []
+ it('shows while nothing is over the picture, and steps away while something is', async () => {
const w = mount(RoomPlate)
- expect(w.find('.lq-here').exists()).toBe(false)
- expect(w.get('h2').text()).toBe('Village Square')
+ expect(w.classes()).not.toContain('lq-plate-away')
+ await w.setProps({ away: true })
+ expect(w.classes()).toContain('lq-plate-away')
})
it('has no Story so far button unless the phone stage asks for it', () => {
expect(mount(RoomPlate).find('.lq-story-so-far').exists()).toBe(false)
})
- it('shows "⟲ Story so far" in the corner on the phone stage, and says when it is pressed', async () => {
+ it('shows "⟲ Story so far" beside the name on the phone stage, and says when it is pressed', async () => {
const w = mount(RoomPlate, { props: { storyButton: true } })
expect(w.get('.lq-story-so-far').text()).toBe('⟲ Story so far')
await w.get('.lq-story-so-far').trigger('click')
expect(w.emitted('story')).toHaveLength(1)
})
- describe('folded', () => {
- it('eases in once when it opens again from folded', async () => {
- const w = mount(RoomPlate, { props: { folded: true } })
- expect(w.classes()).not.toContain('lq-plate-opening')
- await w.setProps({ folded: false })
- expect(w.classes()).toContain('lq-plate-opening')
- await w.trigger('animationend', { animationName: 'lq-plate-fold' })
- expect(w.classes()).not.toContain('lq-plate-opening')
- })
-
- it('is open (with its label) unless folded', () => {
- const w = mount(RoomPlate, { props: { storyButton: true } })
- expect(w.classes()).not.toContain('lq-plate-folded')
- })
-
- it('folds to one row, keeping every name and the button in the open order', () => {
- const open = mount(RoomPlate, { props: { storyButton: true } })
- const folded = mount(RoomPlate, {
- props: { storyButton: true, folded: true },
- })
- expect(folded.classes()).toContain('lq-plate-folded')
- const order = (w: typeof open): string[] =>
- w.findAll('h2, .lq-here-chip, .lq-story-so-far').map((e) => e.text())
- expect(order(folded)).toEqual(order(open))
- expect(folded.get('.lq-label').attributes('aria-hidden')).toBe('true')
- })
- })
-
- describe('compact', () => {
- it('is one row: the label hidden, the name beside the chips in the open order', () => {
- const open = mount(RoomPlate)
- const compact = mount(RoomPlate, { props: { compact: true } })
- expect(compact.classes()).toContain('lq-plate-compact')
- expect(open.classes()).not.toContain('lq-plate-compact')
- expect(compact.get('.lq-label').attributes('aria-hidden')).toBe('true')
- const order = (w: typeof open): string[] =>
- w.findAll('h2, .lq-here-chip').map((e) => e.text())
- expect(order(compact)).toEqual(order(open))
- })
+ it('is smaller on a phone held sideways (compact)', () => {
+ expect(mount(RoomPlate, { props: { compact: true } }).classes()).toContain(
+ 'lq-plate-compact',
+ )
+ expect(mount(RoomPlate).classes()).not.toContain('lq-plate-compact')
})
})
diff --git a/web/src/components/lantern/RoomPlate.vue b/web/src/components/lantern/RoomPlate.vue
index a00774047..1545eeee0 100644
--- a/web/src/components/lantern/RoomPlate.vue
+++ b/web/src/components/lantern/RoomPlate.vue
@@ -1,66 +1,31 @@
+
+
+
+
+ You are in
+ {{ title.name }}
+
+
+
+
+
+
diff --git a/web/src/lantern/lantern.css b/web/src/lantern/lantern.css
index 07974bb43..a618c3632 100644
--- a/web/src/lantern/lantern.css
+++ b/web/src/lantern/lantern.css
@@ -142,6 +142,10 @@
--lq-radius: 14px;
/* 1 = full motion, 0 = reduced; animations scale their movement by it */
--lq-motion: 1;
+ /* Words written straight on the picture (the room's name and title): light, with a soft dark glow in both themes */
+ --lq-picture-ink: #fbf6ea;
+ --lq-picture-glow:
+ 0 0 18px rgb(0 0 0 / 0.85), 0 0 6px rgb(0 0 0 / 0.9), 0 1px 2px #000;
color-scheme: dark;
}
diff --git a/web/src/lantern/stageArrival.test.ts b/web/src/lantern/stageArrival.test.ts
index 55d9f76bf..49dda43b4 100644
--- a/web/src/lantern/stageArrival.test.ts
+++ b/web/src/lantern/stageArrival.test.ts
@@ -90,6 +90,7 @@ describe('arrivalParts', () => {
kind: 'description',
lines: [{ unit: 0, markup: room.description, wordBase: 1 }],
voiced: true,
+ room: 'x',
},
])
})
diff --git a/web/src/lantern/stageArrival.ts b/web/src/lantern/stageArrival.ts
index 289bf9ec9..75de6425a 100644
--- a/web/src/lantern/stageArrival.ts
+++ b/web/src/lantern/stageArrival.ts
@@ -43,6 +43,8 @@ export interface ArrivalPart {
lines: ArrivalLine[]
/** Whether the narrator speaks any of it (it has a recording). */
voiced: boolean
+ /** For `description`: the room's name (the first part of its narration unit), its heading on the picture. */
+ room?: string
/** The first part of an arrival whose talk is told on the sides first (`sideBlocks`): it waits until those words are told. */
afterWords?: true
}
@@ -177,8 +179,13 @@ export function arrivalParts(
wordBase: narrationWords(name).length,
},
]
- // The unit's first part is the room's name, which the plate shows.
- parts.push({ kind: 'description', lines, voiced: spoken(lines, 1) })
+ // The unit's first part is the room's name: the description's heading, not its voice.
+ parts.push({
+ kind: 'description',
+ lines,
+ voiced: spoken(lines, 1),
+ room: name,
+ })
}
if (aside > 0 && parts.length) parts[0].afterWords = true
return parts
diff --git a/web/src/stores/useArrivalStore.test.ts b/web/src/stores/useArrivalStore.test.ts
index 79a3226c0..5ae520853 100644
--- a/web/src/stores/useArrivalStore.test.ts
+++ b/web/src/stores/useArrivalStore.test.ts
@@ -94,6 +94,20 @@ describe('useArrivalStore', () => {
})
afterEach(() => vi.useRealTimers())
+ it('tells a room while an arrival with a description is up, from its first part on', () => {
+ expect(arrival.tellsRoom).toBe(false)
+ arrival.show(1, parts(true, false), false)
+ expect(arrival.tellsRoom).toBe(true)
+ arrival.clear()
+ expect(arrival.tellsRoom).toBe(false)
+ })
+
+ it('tells no room for what happens alone', () => {
+ arrival.show(1, parts(true, false).slice(0, 1), false)
+ expect(arrival.active).toBe(true)
+ expect(arrival.tellsRoom).toBe(false)
+ })
+
describe('when nobody narrates it', () => {
it('stays for its reading time, shown by the bar, then clears', () => {
arrival.show(1, parts(false, false), false)
diff --git a/web/src/stores/useArrivalStore.ts b/web/src/stores/useArrivalStore.ts
index 57f01dcc4..8bf620be2 100644
--- a/web/src/stores/useArrivalStore.ts
+++ b/web/src/stores/useArrivalStore.ts
@@ -73,6 +73,12 @@ export const useArrivalStore = defineStore('arrival', () => {
/** Whether an arrival is on the picture. */
const active = computed(() => !!current.value?.shown)
+ /** Whether the arrival on the picture tells a room (it has a description), not news alone. */
+ const tellsRoom = computed(
+ () =>
+ active.value &&
+ !!current.value?.parts.some((p) => p.kind === 'description'),
+ )
/** The turn whose arrival is up, or null. */
const turnId = computed(() => current.value?.turnId ?? null)
/** The part up now. */
@@ -412,6 +418,7 @@ export const useArrivalStore = defineStore('arrival', () => {
return {
active,
+ tellsRoom,
turnId,
part,
page,
diff --git a/web/src/stores/useRoomTitleStore.test.ts b/web/src/stores/useRoomTitleStore.test.ts
new file mode 100644
index 000000000..bd866c4f2
--- /dev/null
+++ b/web/src/stores/useRoomTitleStore.test.ts
@@ -0,0 +1,181 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { createPinia, setActivePinia } from 'pinia'
+import { ROOM_TITLE_MS, useRoomTitleStore } from './useRoomTitleStore'
+import { useGameStore, type Turn } from './useGameStore'
+import { useSceneStore } from './useSceneStore'
+
+/** A turn that moved the player; `firstVisit` and `narrate` make it an arrival told on the picture. */
+function moveTurn(id: number, firstVisit: boolean): Turn {
+ return {
+ id,
+ narrate: true,
+ arrival: firstVisit ? {} : null,
+ description: { moved: true, firstVisit },
+ } as unknown as Turn
+}
+
+describe('useRoomTitleStore', () => {
+ let game: ReturnType
+ let scene: ReturnType
+ let title: ReturnType
+
+ /** The player is in `name`, after `turn`, with the picture up. */
+ async function enter(name: string, turn: Turn): Promise {
+ game.turns = [...game.turns, turn]
+ game.roomName = name
+ await nextTick()
+ }
+
+ beforeEach(async () => {
+ vi.useFakeTimers()
+ setActivePinia(createPinia())
+ game = useGameStore()
+ scene = useSceneStore()
+ game.showModal = false
+ scene.settled('cottage.png')
+ title = useRoomTitleStore()
+ title.staged = true
+ await enter("Elior's Cottage", moveTurn(1, true))
+ })
+
+ afterEach(() => vi.useRealTimers())
+
+ it('greets a room walked into again with its name', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('leaves a first visit to the arrival, which carries the name', async () => {
+ title.settle()
+ await enter('Village Square', moveTurn(2, true))
+ expect(title.name).toBeNull()
+ })
+
+ it('settles after its time', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ vi.advanceTimersByTime(ROOM_TITLE_MS - 1)
+ expect(title.name).toBe('Village Square')
+ vi.advanceTimersByTime(1)
+ expect(title.name).toBeNull()
+ })
+
+ it('settles at once when asked', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ expect(title.name).toBeNull()
+ vi.advanceTimersByTime(ROOM_TITLE_MS)
+ expect(title.name).toBeNull()
+ })
+
+ it('greets a room once, not again for a look in it', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ game.turns = [...game.turns, moveTurn(3, false)]
+ await nextTick()
+ expect(title.name).toBeNull()
+ })
+
+ it('greets the room again when a game is loaded in it', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ game.loadNumber++
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('greets a room walked back into before the change of scene to another was up', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ scene.phase = 'in'
+ await enter('Village Well', moveTurn(3, false))
+ await enter('Village Square', moveTurn(4, false))
+ scene.phase = 'idle'
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('greets the room again once a fresh stage is up (a load), not over the old picture', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ title.reset()
+ scene.reset()
+ await nextTick()
+ expect(title.name).toBeNull()
+ scene.settled('square.png')
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('after a load into another room, waits out the old picture and greets over the new one, or the backdrop when it has none', async () => {
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ game.musicRoomKey = 'VillageWell'
+ title.reset()
+ scene.reset()
+ scene.settled('cottage.png')
+ await enter('Village Well', moveTurn(3, false))
+ expect(title.name).toBeNull()
+ scene.reset()
+ scene.settled('')
+ await nextTick()
+ expect(title.name).toBe('Village Well')
+ })
+
+ it('greets a load in a room with no painted picture, the backdrop up before and after', async () => {
+ // The room has a picture's address, but nothing is painted there.
+ game.musicActName = 'Act1'
+ game.musicRoomKey = 'VillageSquare'
+ scene.reset()
+ scene.settled('')
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ title.reset()
+ scene.reset()
+ scene.settled('')
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('greets a load from one unpainted room into another', async () => {
+ game.musicActName = 'Act1'
+ game.musicRoomKey = 'VillageSquare'
+ scene.reset()
+ scene.settled('')
+ await enter('Village Square', moveTurn(2, false))
+ title.settle()
+ game.musicRoomKey = 'VillageWell'
+ title.reset()
+ scene.reset()
+ scene.settled('')
+ await enter('Village Well', moveTurn(3, false))
+ expect(title.name).toBe('Village Well')
+ })
+
+ it('waits for a change of scene to bring the picture up', async () => {
+ scene.phase = 'in'
+ await enter('Village Square', moveTurn(2, false))
+ expect(title.name).toBeNull()
+ scene.phase = 'idle'
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('waits while a chapter page covers the stage', async () => {
+ game.showModal = true
+ await enter('Village Square', moveTurn(2, false))
+ expect(title.name).toBeNull()
+ game.showModal = false
+ await nextTick()
+ expect(title.name).toBe('Village Square')
+ })
+
+ it('shows nothing where there is no stage (the text page), nor later for that room', async () => {
+ title.staged = false
+ await enter('Village Square', moveTurn(2, false))
+ expect(title.name).toBeNull()
+ title.staged = true
+ await nextTick()
+ expect(title.name).toBeNull()
+ })
+})
diff --git a/web/src/stores/useRoomTitleStore.ts b/web/src/stores/useRoomTitleStore.ts
new file mode 100644
index 000000000..d72f3a604
--- /dev/null
+++ b/web/src/stores/useRoomTitleStore.ts
@@ -0,0 +1,109 @@
+import { defineStore } from 'pinia'
+import { ref, watch } from 'vue'
+import { arrivesOnPicture } from '@/lantern/stageArrival'
+import { useGameStore } from './useGameStore'
+import { useSceneStore } from './useSceneStore'
+
+/** How long the room's title stays over the picture before it settles into the corner (ms): about 0.6 s to fade in, then 3 s. */
+export const ROOM_TITLE_MS = 3600
+
+/**
+ * The room's title over the middle of the picture: the player walks into a
+ * room, or a game is loaded, and the room's name is told there ("You are in")
+ * before it settles as the small name in the corner (`RoomPlate`). A room is
+ * greeted once per arrival, when the stage shows (`staged`), no chapter page
+ * covers it and its picture is up (after a change of scene). A first visit is
+ * not greeted here: its arrival is told on the picture with the name as its
+ * heading. The name switches as the narrator reaches the room (the game
+ * store's held `roomName`), so the title follows the voice; walking on before
+ * a change of scene is up greets only the room the player ends in. Nothing is saved.
+ */
+export const useRoomTitleStore = defineStore('roomTitle', () => {
+ const game = useGameStore()
+ const scene = useSceneStore()
+
+ /** The name the title shows while it is up; null when settled. */
+ const name = ref(null)
+ /** Whether the room is played on its picture (the layout says): no title on the text page. */
+ const staged = ref(false)
+ /** The room changed (or a game was started or loaded) and has not been greeted or let pass yet. */
+ let pending = false
+ let timer: ReturnType | undefined
+
+ /** Put the title up for `room`, settling after `ROOM_TITLE_MS`. */
+ function greet(room: string): void {
+ clearTimeout(timer)
+ name.value = room
+ timer = setTimeout(settle, ROOM_TITLE_MS)
+ }
+
+ /** Settle the title into the corner now. */
+ function settle(): void {
+ clearTimeout(timer)
+ timer = undefined
+ name.value = null
+ }
+
+ /**
+ * The picture that was up when the stage last started afresh, while it may
+ * still be the one settled: the stage settles it again at once, before the
+ * new room's picture has begun to load. Null once another has settled, and
+ * never the backdrop ('').
+ */
+ let stale: string | null = null
+
+ /** The stage starts afresh (a new game, a load, a new act): settle, and greet the room once its own picture is up. */
+ function reset(): void {
+ settle()
+ pending = true
+ // The paper backdrop is never stale: it is what a room without a painted
+ // picture settles on again, and it shows no other room.
+ stale = scene.shown || null
+ }
+
+ /** Whether a greeting can be shown now: a room, no chapter page, and its picture up after any change of scene. */
+ function ready(): boolean {
+ if (stale !== null && scene.shown !== null && scene.shown !== stale)
+ stale = null
+ // The old picture settled again after a load into a room with another one.
+ if (stale !== null && scene.shown === stale && game.roomPicture !== stale)
+ return false
+ return (
+ game.roomName !== '' &&
+ !game.showModal &&
+ scene.phase === 'idle' &&
+ scene.shown !== null
+ )
+ }
+
+ /** Greet the room waiting for it, or let it pass: on the text page, or on a first visit, whose arrival is headed by the name. */
+ function consider(): void {
+ if (!pending || !ready()) return
+ pending = false
+ const latest = game.turns.at(-1)
+ if (!staged.value || (latest && arrivesOnPicture(latest))) settle()
+ else greet(game.roomName)
+ }
+
+ watch(
+ () => [game.gameNumber, game.loadNumber, game.roomName] as const,
+ () => {
+ pending = true
+ consider()
+ },
+ { immediate: true },
+ )
+ watch(
+ () =>
+ [
+ game.showModal,
+ scene.phase,
+ scene.shown,
+ game.roomPicture,
+ staged.value,
+ ] as const,
+ consider,
+ )
+
+ return { name, staged, greet, settle, reset }
+})