@frotzinc/zcode-ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +27 -2
- package/app/components/AppNav.vue +33 -0
- package/app/components/CopyText.vue +24 -0
- package/app/components/Disassembly.vue +35 -0
- package/app/components/GlobalsView.vue +75 -0
- package/app/components/HexPane.vue +93 -0
- package/app/components/MapView.vue +143 -0
- package/app/components/NotFound.vue +11 -0
- package/app/components/ObjectTree.vue +176 -0
- package/app/components/RefLink.vue +11 -0
- package/app/components/Referrers.vue +45 -0
- package/app/components/SearchPalette.vue +59 -0
- package/app/components/StatTile.vue +15 -0
- package/app/components/ViewHeader.vue +16 -0
- package/app/composables/useHashRoute.ts +47 -0
- package/app/composables/useSymbols.ts +69 -0
- package/app/utils/live.ts +19 -0
- package/app/utils/map-layout.ts +98 -0
- package/app/utils/nav.ts +10 -0
- package/app/utils/story.ts +87 -0
- package/nuxt.config.ts +12 -0
- package/package.json +53 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@frotzinc/zcode-ui`. Versions follow [Semantic Versioning](https://semver.org/). Part of [frotz, Inc.](https://frotzinc.com).
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-11-08
|
|
6
|
+
|
|
7
|
+
The first release: frotzlens's story views, moved out of the app (and relicensed MIT by their author) so frotztrace can show a running story with the same components.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Views:** `ObjectTree`, `GlobalsView`, `MapView`, `SearchPalette`, and `AppNav`, which now takes the app's list of views (`views`, `active`) instead of building frotzlens's.
|
|
11
|
+
- **New, split out of frotzlens's views:** `Disassembly` (from the routine view) and `HexPane` (from the memory view; `v-model:top`, `notes`, `inRange`).
|
|
12
|
+
- **Parts:** `RefLink`, `Referrers`, `ViewHeader`, `CopyText`, `StatTile`, `NotFound`.
|
|
13
|
+
- **Composables and utils:** `useHashRoute`, `useSymbols`, `activeSymbols`, the naming and routing utils (`hex`, `idRoute`, `idLabel`, `routineName`, …), `useLimit`, `storyIndex`, `layoutMap`.
|
|
14
|
+
- **`MachineView`**, the type of the optional `live` prop the views take; a placeholder until `@frotzinc/zmachine` exports the real one. A view given no `live` (or one, in this release) renders as before.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christopher Schweda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @frotzinc/zcode-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Z-machine story views of [frotzlens](https://frotzlens.frotzinc.com), as a Nuxt layer, so that frotzlens (a story file at rest) and frotztrace (a story running under a debugger) show a story with the same components, the same links and the same look. Part of [frotz, Inc.](https://frotzinc.com)
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// nuxt.config.ts
|
|
7
|
+
export default defineNuxtConfig({
|
|
8
|
+
extends: ['@frotzinc/nuxt-base', '@frotzinc/zcode-ui'],
|
|
9
|
+
modules: ['@nuxt/ui'],
|
|
10
|
+
})
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Everything below is auto-imported. The story is a `StoryFile` from [`@frotzinc/zcode`](https://www.npmjs.com/package/@frotzinc/zcode).
|
|
14
|
+
|
|
15
|
+
| | |
|
|
16
|
+
|---|---|
|
|
17
|
+
| **Views** | `ObjectTree` (an ARIA tree), `GlobalsView`, `MapView`, `Disassembly` (one routine, txd's layout, every operand a link), `HexPane` (paged hex with "Go to address"), `SearchPalette` (⌘K / Ctrl+K), `AppNav` (takes the app's own list of views) |
|
|
18
|
+
| **Parts** | `RefLink`, `Referrers`, `ViewHeader`, `CopyText`, `StatTile`, `NotFound` |
|
|
19
|
+
| **Composables** | `useHashRoute` (the view is the URL hash: `#routine/27f8`), `useSymbols` (names from Inform's `gameinfo.dbg`; `activeSymbols`) |
|
|
20
|
+
| **Utils** | `hex`, `idRoute`, `idLabel`, `routineName`, `objectName`, `globalName`, `attributeName`, `propertyName`, `actionName`, `useLimit`, `storyIndex`, `layoutMap`; the types `MachineView` and `NavView` (also importable as `@frotzinc/zcode-ui/utils/live` and `/utils/nav`) |
|
|
21
|
+
|
|
22
|
+
**The look is not here.** Colors, fonts and the family shell (header, footer, About menu) are [`@frotzinc/nuxt-base`](https://www.npmjs.com/package/@frotzinc/nuxt-base)'s, and this layer adds none, so frotzsmith, frotzlens, frotzplay and frotztrace look like one product, dark by default.
|
|
23
|
+
|
|
24
|
+
**`live`.** `ObjectTree`, `GlobalsView`, `MapView`, `Disassembly` and `HexPane` take an optional `live` prop, a `MachineView` (the program counter, the frame, what changed since the last stop). In 0.1 it is accepted and ignored: without it, and with it, a view renders exactly as frotzlens has always shown it. frotztrace's later releases light what changed.
|
|
25
|
+
|
|
26
|
+
Peer dependencies: `nuxt` ^4.6, `@nuxt/ui` ^4.12, `vue` ^3.5.
|
|
27
|
+
|
|
28
|
+
MIT © Christopher Schweda. Extracted from frotzlens (GPL-3.0-or-later), whose author relicensed these files MIT for the move.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NavView } from '../utils/nav'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The views, left of the content on wide screens and a scrolling row above it on narrow ones. Each
|
|
6
|
+
* app supplies its own list; `active` is the key of the view being shown.
|
|
7
|
+
*/
|
|
8
|
+
const props = defineProps<{ views: NavView[]; active: string }>()
|
|
9
|
+
|
|
10
|
+
// On a narrow screen the nav is a scrolling row: keep the current view in sight.
|
|
11
|
+
const nav = useTemplateRef<HTMLElement>('nav')
|
|
12
|
+
watch(() => props.active, () => nextTick(() => nav.value?.querySelector('[aria-current="page"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })), { immediate: true })
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<nav ref="nav" aria-label="Views" class="lg:sticky lg:top-20 lg:self-start">
|
|
17
|
+
<ul class="-mx-1 flex gap-1 overflow-x-auto pb-1 lg:mx-0 lg:w-52 lg:flex-col lg:overflow-visible">
|
|
18
|
+
<li v-for="v in views" :key="v.key">
|
|
19
|
+
<a
|
|
20
|
+
:href="`#${v.key}`"
|
|
21
|
+
:aria-current="active === v.key ? 'page' : undefined"
|
|
22
|
+
class="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm transition-colors hover:bg-elevated"
|
|
23
|
+
:class="active === v.key ? 'bg-elevated font-semibold text-highlighted' : 'text-muted'"
|
|
24
|
+
>
|
|
25
|
+
<UIcon :name="v.icon" class="size-4 shrink-0" :class="active === v.key ? 'text-lamp' : ''" aria-hidden="true" />
|
|
26
|
+
{{ v.label }}
|
|
27
|
+
<span v-if="v.count !== undefined" class="data ml-auto pl-2 text-xs text-muted">{{ v.count }}</span>
|
|
28
|
+
<span v-else-if="v.tag" class="ml-auto rounded-full border border-default px-1.5 text-xs text-muted">{{ v.tag }}</span>
|
|
29
|
+
</a>
|
|
30
|
+
</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</nav>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** "Copy as text": the view's section in Ztools' format, made only when asked for. */
|
|
3
|
+
const props = defineProps<{ text: () => string; label?: string }>()
|
|
4
|
+
const status = ref('')
|
|
5
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
6
|
+
|
|
7
|
+
async function copy() {
|
|
8
|
+
try {
|
|
9
|
+
await navigator.clipboard.writeText(props.text())
|
|
10
|
+
status.value = 'Copied'
|
|
11
|
+
} catch {
|
|
12
|
+
status.value = "Couldn't copy: the browser didn't allow it"
|
|
13
|
+
}
|
|
14
|
+
clearTimeout(timer)
|
|
15
|
+
timer = setTimeout(() => (status.value = ''), 2500)
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex items-center gap-3">
|
|
21
|
+
<UButton color="neutral" variant="outline" icon="i-lucide-copy" @click="copy">{{ label ?? 'Copy as text' }}</UButton>
|
|
22
|
+
<span role="status" aria-live="polite" class="text-sm text-muted">{{ status }}</span>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { RoutineView } from '@frotzinc/zcode/format'
|
|
3
|
+
import type { MachineView } from '../utils/live'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One routine's disassembly, as txd lays it out: every routine, string, object and dictionary word
|
|
7
|
+
* it uses is a link, and a branch or jump target moves focus to its line. `live` is unused until
|
|
8
|
+
* frotztrace's Phase 4, when it marks the program counter.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{ view: RoutineView; address: number; live?: MachineView }>()
|
|
11
|
+
|
|
12
|
+
// Labels: a branch or jump target scrolls to its line and focuses it (the route stays the routine).
|
|
13
|
+
function jump(target: number) {
|
|
14
|
+
const el = document.getElementById(`at-${hex(target)}`)
|
|
15
|
+
el?.scrollIntoView({ block: 'center' })
|
|
16
|
+
el?.focus()
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="overflow-x-auto rounded-xl border border-default bg-muted" role="region" aria-label="Disassembly" tabindex="0">
|
|
22
|
+
<div class="data min-w-max p-4 text-sm leading-6">
|
|
23
|
+
<div class="whitespace-pre text-highlighted">{{ props.view.header }}</div>
|
|
24
|
+
<div v-for="(n, i) in props.view.notes" :key="i" class="whitespace-pre text-muted">{{ n }}</div>
|
|
25
|
+
<div aria-hidden="true"> </div>
|
|
26
|
+
<div
|
|
27
|
+
v-for="l in props.view.lines"
|
|
28
|
+
:id="`at-${hex(l.address)}`"
|
|
29
|
+
:key="l.address"
|
|
30
|
+
tabindex="-1"
|
|
31
|
+
class="whitespace-pre rounded focus:bg-elevated"
|
|
32
|
+
><span class="text-muted">{{ `${hex(l.address).padStart(5)} ` }}</span><span class="text-lamp">{{ (l.label ? `${l.label}:` : '').padEnd(7) }}</span><template v-for="(p, j) in l.pieces" :key="j"><a v-if="p.id" :href="idRoute(p.id)" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid">{{ p.text }}</a><a v-else-if="p.target !== undefined" :href="`#routine/${hex(address)}`" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid" :aria-label="`${p.text}, the instruction at ${hex(p.target)}`" @click.prevent="jump(p.target)">{{ p.text }}</a><span v-else>{{ p.text }}</span></template></div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { StoryFile } from '@frotzinc/zcode'
|
|
3
|
+
import type { MachineView } from '../utils/live'
|
|
4
|
+
|
|
5
|
+
/** The 240 global variables: their starting values, names (with a debug file) and the routines that use them. */
|
|
6
|
+
const props = defineProps<{ story: StoryFile; fileName: string; id?: string; live?: MachineView }>()
|
|
7
|
+
|
|
8
|
+
const number = computed(() => (props.id && /^\d+$/.test(props.id) ? Number(props.id) : undefined))
|
|
9
|
+
const global = computed(() => (number.value === undefined ? undefined : props.story.globals[number.value]))
|
|
10
|
+
|
|
11
|
+
const filter = ref('')
|
|
12
|
+
const rows = computed(() => {
|
|
13
|
+
const s = props.story
|
|
14
|
+
const q = filter.value.trim().toLowerCase()
|
|
15
|
+
return s.globals
|
|
16
|
+
// Uses: each place an instruction reads or writes it, as the detail's "Used by" counts them.
|
|
17
|
+
.map(g => ({ g, name: globalName(g.number), txd: `G${hex(g.number, 2)}`, uses: s.xrefs[g.id]?.from.length ?? 0 }))
|
|
18
|
+
.filter(r => !q || r.name.toLowerCase().includes(q) || r.txd.toLowerCase() === q || hex(r.g.value).includes(q.replace(/^#|0x/, '')))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/** As text: each global, its name if any, and its starting value. */
|
|
22
|
+
const asText = () =>
|
|
23
|
+
props.story.globals
|
|
24
|
+
.map(g => `G${hex(g.number, 2)} ${hex(g.value)}${activeSymbols.value?.globals[g.number] ? ` ${activeSymbols.value.globals[g.number]}` : ''}`)
|
|
25
|
+
.join('\n') + '\n'
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div v-if="id === undefined" class="flex flex-col gap-6">
|
|
30
|
+
<ViewHeader title="Globals" :eyebrow="fileName" :copy="asText" copy-label="Copy the globals">
|
|
31
|
+
The 240 global variables (G00–Gef), with the value each starts with and the routines that use it.
|
|
32
|
+
<template v-if="!activeSymbols"> Load the game’s debug file to see their names.</template>
|
|
33
|
+
</ViewHeader>
|
|
34
|
+
<UInput v-model="filter" icon="i-lucide-search" placeholder="Search names, G-numbers or values" aria-label="Search globals" class="max-w-md" />
|
|
35
|
+
<div class="overflow-x-auto rounded-xl border border-default">
|
|
36
|
+
<table class="w-full text-sm">
|
|
37
|
+
<caption class="sr-only">Global variables</caption>
|
|
38
|
+
<thead class="bg-muted text-left text-muted">
|
|
39
|
+
<tr>
|
|
40
|
+
<th scope="col" class="px-4 py-2 font-semibold">Global</th>
|
|
41
|
+
<th v-if="activeSymbols" scope="col" class="px-4 py-2 font-semibold">Number</th>
|
|
42
|
+
<th scope="col" class="px-4 py-2 font-semibold">Address</th>
|
|
43
|
+
<th scope="col" class="px-4 py-2 font-semibold">Starts as</th>
|
|
44
|
+
<th scope="col" class="px-4 py-2 text-right font-semibold">Used by</th>
|
|
45
|
+
</tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
<tr v-for="r in rows" :key="r.g.number" class="border-t border-default">
|
|
49
|
+
<td class="data px-4 py-1.5"><a :href="`#global/${r.g.number}`" class="text-lamp hover:underline">{{ r.name }}</a></td>
|
|
50
|
+
<td v-if="activeSymbols" class="data px-4 py-1.5 text-muted">{{ r.txd }}</td>
|
|
51
|
+
<td class="data px-4 py-1.5 text-muted">{{ hex(r.g.address) }}</td>
|
|
52
|
+
<td class="data px-4 py-1.5">{{ hex(r.g.value) }} <span class="text-muted">({{ r.g.value }})</span></td>
|
|
53
|
+
<td class="data px-4 py-1.5 text-right">{{ r.uses }}</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
</div>
|
|
58
|
+
<p v-if="!rows.length" class="text-muted">{{ story.globals.length ? `No global matches “${filter}”.` : 'This file has no globals table.' }}</p>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div v-else-if="global" class="flex flex-col gap-6">
|
|
62
|
+
<ViewHeader
|
|
63
|
+
:title="globalName(global.number)"
|
|
64
|
+
:eyebrow="`G${hex(global.number, 2)} · variable ${global.number + 16} · at ${hex(global.address)}`"
|
|
65
|
+
:back="{ to: '#globals', label: 'All globals' }"
|
|
66
|
+
/>
|
|
67
|
+
<dl class="grid w-fit grid-cols-[auto_auto] gap-x-6 gap-y-1 text-sm">
|
|
68
|
+
<dt class="text-muted">Starts as</dt>
|
|
69
|
+
<dd class="data">{{ hex(global.value) }} ({{ global.value }})</dd>
|
|
70
|
+
</dl>
|
|
71
|
+
<Referrers :story="story" :id="global.id" from-title="Used by" />
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<NotFound v-else :what="`global ${id}`" back="#globals" back-label="All globals" />
|
|
75
|
+
</template>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { MachineView } from '../utils/live'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A hex view of bytes, a page of rows at a time so a large file stays quick, with "Go to address"
|
|
6
|
+
* and paging. `top` (v-model) is the first row's address. `notes` labels what begins in a row;
|
|
7
|
+
* `inRange` dims the bytes outside the part being shown. `live` is unused until frotztrace's
|
|
8
|
+
* Phase 4, when it lights the bytes a running machine changed.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
bytes: Uint8Array
|
|
12
|
+
notes?: (from: number, to: number) => string[]
|
|
13
|
+
inRange?: (address: number) => boolean
|
|
14
|
+
live?: MachineView
|
|
15
|
+
}>()
|
|
16
|
+
const top = defineModel<number>('top', { default: 0 })
|
|
17
|
+
|
|
18
|
+
const ROWS = 32
|
|
19
|
+
const size = computed(() => props.bytes.length)
|
|
20
|
+
|
|
21
|
+
/** Jump to a row-aligned address. */
|
|
22
|
+
const goTo = (a: number) => (top.value = Math.max(0, Math.min(Math.floor(a / 16) * 16, Math.max(0, size.value - 1) & ~15)))
|
|
23
|
+
|
|
24
|
+
const rows = computed(() => {
|
|
25
|
+
const b = props.bytes
|
|
26
|
+
const out: { at: number; bytes: number[]; text: string; notes: string[] }[] = []
|
|
27
|
+
for (let r = 0; r < ROWS; r++) {
|
|
28
|
+
const at = top.value + r * 16
|
|
29
|
+
if (at >= b.length) break
|
|
30
|
+
const bytes = [...b.subarray(at, Math.min(at + 16, b.length))]
|
|
31
|
+
out.push({
|
|
32
|
+
at,
|
|
33
|
+
bytes,
|
|
34
|
+
text: bytes.map(x => (x >= 32 && x < 127 ? String.fromCharCode(x) : '.')).join(''),
|
|
35
|
+
notes: props.notes?.(at, at + 16) ?? [],
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
})
|
|
40
|
+
const inPart = (a: number) => props.inRange?.(a) ?? true
|
|
41
|
+
|
|
42
|
+
const jump = ref('')
|
|
43
|
+
const jumpError = ref('')
|
|
44
|
+
function onJump() {
|
|
45
|
+
const text = jump.value.trim().replace(/^(0x|\$)/i, '')
|
|
46
|
+
const a = /^[0-9a-f]+$/i.test(text) ? parseInt(text, 16) : NaN
|
|
47
|
+
if (!size.value) jumpError.value = 'The file is empty: there are no addresses.'
|
|
48
|
+
else if (Number.isNaN(a)) jumpError.value = `“${jump.value}” isn't a hex address.`
|
|
49
|
+
else if (a >= size.value) jumpError.value = `${hex(a)} is past the end of the file (${hex(size.value - 1)}).`
|
|
50
|
+
else {
|
|
51
|
+
jumpError.value = ''
|
|
52
|
+
goTo(a)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<section aria-labelledby="hex-title" class="flex flex-col gap-4">
|
|
59
|
+
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
60
|
+
<h2 id="hex-title" class="font-display text-2xl font-semibold text-highlighted">Bytes</h2>
|
|
61
|
+
<form class="flex items-center gap-2" @submit.prevent="onJump">
|
|
62
|
+
<label for="hex-jump" class="text-sm text-muted">Go to address</label>
|
|
63
|
+
<UInput id="hex-jump" v-model="jump" placeholder="1c97" class="data w-28" :aria-invalid="!!jumpError || undefined" />
|
|
64
|
+
<UButton type="submit" color="neutral" variant="outline">Go</UButton>
|
|
65
|
+
</form>
|
|
66
|
+
</div>
|
|
67
|
+
<p v-if="jumpError" role="alert" class="text-sm text-error">{{ jumpError }}</p>
|
|
68
|
+
<div v-if="!size">
|
|
69
|
+
<p class="text-muted">The file is empty.</p>
|
|
70
|
+
</div>
|
|
71
|
+
<div v-if="size" class="flex flex-wrap items-center gap-2">
|
|
72
|
+
<UButton color="neutral" variant="outline" icon="i-lucide-chevrons-up" :disabled="top === 0" aria-label="Previous page of bytes" @click="goTo(top - ROWS * 16)" />
|
|
73
|
+
<UButton color="neutral" variant="outline" icon="i-lucide-chevrons-down" :disabled="top + ROWS * 16 >= size" aria-label="Next page of bytes" @click="goTo(top + ROWS * 16)" />
|
|
74
|
+
<span class="data text-sm text-muted">{{ hex(top) }}–{{ hex(Math.min(top + ROWS * 16, size) - 1) }} of {{ hex(size - 1) }}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div v-if="size" class="overflow-x-auto rounded-xl border border-default bg-muted" role="region" aria-label="Bytes" tabindex="0">
|
|
77
|
+
<table class="data min-w-max text-sm leading-6">
|
|
78
|
+
<caption class="sr-only">Bytes from {{ hex(top) }}, 16 to a row</caption>
|
|
79
|
+
<thead class="sr-only">
|
|
80
|
+
<tr><th scope="col">Address</th><th scope="col">Bytes</th><th scope="col">As text</th><th scope="col">Starts here</th></tr>
|
|
81
|
+
</thead>
|
|
82
|
+
<tbody>
|
|
83
|
+
<tr v-for="r in rows" :key="r.at">
|
|
84
|
+
<th scope="row" class="px-4 text-left font-normal text-muted">{{ hex(r.at, 5) }}</th>
|
|
85
|
+
<td class="whitespace-pre px-2"><template v-for="(b, i) in r.bytes" :key="i"><span :class="inPart(r.at + i) ? 'text-highlighted' : 'text-muted'">{{ hex(b, 2) }}</span>{{ i === 7 ? ' ' : i < 15 ? ' ' : '' }}</template></td>
|
|
86
|
+
<td class="whitespace-pre px-4 text-muted">{{ r.text }}</td>
|
|
87
|
+
<td class="whitespace-nowrap px-4 font-sans text-xs text-lamp">{{ r.notes.join(' · ') }}</td>
|
|
88
|
+
</tr>
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
</template>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { roomGraph, toMapGraph, type RoomExit, type StoryFile } from '@frotzinc/zcode'
|
|
3
|
+
import type { MachineView } from '../utils/live'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The map of an Inform game, from its rooms' exits (which need the debug file
|
|
7
|
+
* to be found). Drawn on a grid, as frotzsmith's map is, and listed as text
|
|
8
|
+
* below — the list is the map for anyone who can't see the picture.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{ story: StoryFile; fileName: string; live?: MachineView }>()
|
|
11
|
+
const { symbols } = useSymbols()
|
|
12
|
+
|
|
13
|
+
const graph = computed(() => (symbols.value ? roomGraph(props.story, symbols.value) : undefined))
|
|
14
|
+
const map = computed(() => (graph.value ? layoutMap(graph.value) : undefined))
|
|
15
|
+
|
|
16
|
+
const CELL = 170
|
|
17
|
+
const W = 136
|
|
18
|
+
const H = 52
|
|
19
|
+
const PAD = 24
|
|
20
|
+
const box = computed(() => {
|
|
21
|
+
const b = map.value?.bounds ?? { minCol: 0, maxCol: 0, minRow: 0, maxRow: 0 }
|
|
22
|
+
const x = b.minCol * CELL - W / 2 - PAD
|
|
23
|
+
const y = b.minRow * CELL - H / 2 - PAD
|
|
24
|
+
return { x, y, w: (b.maxCol - b.minCol) * CELL + W + PAD * 2, h: (b.maxRow - b.minRow) * CELL + H + PAD * 2 }
|
|
25
|
+
})
|
|
26
|
+
const at = computed(() => new Map(map.value?.rooms.map(r => [r.number, r]) ?? []))
|
|
27
|
+
const centre = (n: number) => {
|
|
28
|
+
const r = at.value.get(n)!
|
|
29
|
+
return { x: r.col * CELL, y: r.row * CELL }
|
|
30
|
+
}
|
|
31
|
+
const short = (s: string) => (s.length > 18 ? `${s.slice(0, 17)}…` : s)
|
|
32
|
+
|
|
33
|
+
const DIRS: Record<string, string> = {
|
|
34
|
+
n: 'north', s: 'south', e: 'east', w: 'west', ne: 'northeast', nw: 'northwest', se: 'southeast', sw: 'southwest',
|
|
35
|
+
u: 'up', d: 'down', in: 'in', out: 'out',
|
|
36
|
+
}
|
|
37
|
+
// A room's label: its short name, with its number when another room has the same name (as toMapGraph does).
|
|
38
|
+
const nameCount = computed(() => {
|
|
39
|
+
const m = new Map<string, number>()
|
|
40
|
+
for (const r of graph.value?.rooms ?? []) m.set(objectName(props.story, r.number), (m.get(objectName(props.story, r.number)) ?? 0) + 1)
|
|
41
|
+
return m
|
|
42
|
+
})
|
|
43
|
+
const name = (n: number) => {
|
|
44
|
+
const plain = objectName(props.story, n)
|
|
45
|
+
return (nameCount.value.get(plain) ?? 0) > 1 ? `${plain} (${n})` : plain
|
|
46
|
+
}
|
|
47
|
+
function exitText(e: RoomExit): string {
|
|
48
|
+
if (e.kind === 'room') return ''
|
|
49
|
+
if (e.kind === 'door') return `through ${name(e.door!)}`
|
|
50
|
+
if (e.kind === 'routine') return e.candidates?.length ? 'decided as the game runs: perhaps' : 'decided as the game runs'
|
|
51
|
+
return 'no way through (a message)'
|
|
52
|
+
}
|
|
53
|
+
// Each room's exits, indexed once (a big map would otherwise rescan every exit per room).
|
|
54
|
+
const exitIndex = computed(() => {
|
|
55
|
+
const m = new Map<number, RoomExit[]>()
|
|
56
|
+
for (const e of graph.value?.exits ?? []) m.set(e.from, [...(m.get(e.from) ?? []), e])
|
|
57
|
+
return m
|
|
58
|
+
})
|
|
59
|
+
const exitsFrom = (n: number) => exitIndex.value.get(n) ?? []
|
|
60
|
+
const LINK = 'text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid'
|
|
61
|
+
const copy = () => JSON.stringify(toMapGraph(graph.value!), null, 2) + '\n'
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div class="flex flex-col gap-6">
|
|
66
|
+
<ViewHeader title="Map" :eyebrow="fileName" :copy="graph ? copy : undefined" copy-label="Copy the map (frotzsmith's shape)">
|
|
67
|
+
The rooms and the exits between them, from each room’s direction properties. Exits worked out as the
|
|
68
|
+
game runs are drawn to the rooms their routine can return, dashed.
|
|
69
|
+
</ViewHeader>
|
|
70
|
+
|
|
71
|
+
<div v-if="!symbols" class="flex flex-col gap-3 rounded-xl border border-default p-5">
|
|
72
|
+
<p>The map needs the game’s debug file: it finds exits by their property names (<code class="data">n_to</code>, <code class="data">s_to</code>, …).</p>
|
|
73
|
+
<a href="#symbols" class="w-fit text-lamp hover:underline">Load a debug file</a>
|
|
74
|
+
</div>
|
|
75
|
+
<p v-else-if="!graph" class="text-muted">This game has no direction properties to find exits by.</p>
|
|
76
|
+
|
|
77
|
+
<template v-else-if="map">
|
|
78
|
+
<p class="text-sm text-muted">
|
|
79
|
+
{{ graph.rooms.length }} {{ graph.rooms.length === 1 ? 'room' : 'rooms' }}<template v-if="graph.start">; the game starts in {{ name(graph.start) }}</template>.
|
|
80
|
+
</p>
|
|
81
|
+
<div class="overflow-x-auto rounded-xl border border-default bg-muted">
|
|
82
|
+
<svg
|
|
83
|
+
:viewBox="`${box.x} ${box.y} ${box.w} ${box.h}`"
|
|
84
|
+
:width="box.w"
|
|
85
|
+
:height="box.h"
|
|
86
|
+
class="mx-auto block max-w-none"
|
|
87
|
+
role="group"
|
|
88
|
+
:aria-label="`A map of ${graph.rooms.length} rooms; each links to its object. The list below gives each room's exits.`"
|
|
89
|
+
>
|
|
90
|
+
<g aria-hidden="true">
|
|
91
|
+
<line
|
|
92
|
+
v-for="(c, i) in map.connectors"
|
|
93
|
+
:key="i"
|
|
94
|
+
:x1="centre(c.from).x"
|
|
95
|
+
:y1="centre(c.from).y"
|
|
96
|
+
:x2="centre(c.to).x"
|
|
97
|
+
:y2="centre(c.to).y"
|
|
98
|
+
stroke="var(--frotz-lamp-text)"
|
|
99
|
+
stroke-width="2"
|
|
100
|
+
:stroke-dasharray="c.routine ? '6 5' : undefined"
|
|
101
|
+
stroke-opacity="0.7"
|
|
102
|
+
/>
|
|
103
|
+
</g>
|
|
104
|
+
<a v-for="r in map.rooms" :key="r.number" :href="`#object/${r.number}`">
|
|
105
|
+
<title>{{ name(r.number) }}</title>
|
|
106
|
+
<rect
|
|
107
|
+
:x="r.col * CELL - W / 2"
|
|
108
|
+
:y="r.row * CELL - H / 2"
|
|
109
|
+
:width="W"
|
|
110
|
+
:height="H"
|
|
111
|
+
rx="8"
|
|
112
|
+
fill="var(--ui-bg-elevated)"
|
|
113
|
+
:stroke="r.number === graph.start ? 'var(--frotz-lamp-text)' : 'var(--ui-border-accented)'"
|
|
114
|
+
:stroke-width="r.number === graph.start ? 3 : 1.5"
|
|
115
|
+
/>
|
|
116
|
+
<text :x="r.col * CELL" :y="r.row * CELL + 5" text-anchor="middle" font-size="14" fill="var(--ui-text-highlighted)">{{ short(name(r.number)) }}</text>
|
|
117
|
+
</a>
|
|
118
|
+
</svg>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<section aria-labelledby="rooms-title" class="flex flex-col gap-3">
|
|
122
|
+
<h2 id="rooms-title" class="font-display text-xl font-semibold text-highlighted">Rooms and exits</h2>
|
|
123
|
+
<ul class="flex flex-col gap-3 text-sm">
|
|
124
|
+
<li v-for="r in graph.rooms" :key="r.number">
|
|
125
|
+
<a :href="`#object/${r.number}`" class="font-semibold text-lamp hover:underline">{{ name(r.number) }}</a>
|
|
126
|
+
<span v-if="r.number === graph.start" class="ml-2 text-xs text-muted">where the game starts</span>
|
|
127
|
+
<ul v-if="exitsFrom(r.number).length" class="mt-1 flex flex-col gap-0.5 pl-4">
|
|
128
|
+
<li v-for="(e, i) in exitsFrom(r.number)" :key="i">
|
|
129
|
+
<span class="data inline-block w-24 text-muted">{{ DIRS[e.dir] }}</span>
|
|
130
|
+
{{ exitText(e) }}
|
|
131
|
+
<!-- Links inside a sentence are underlined, so they don't rely on colour alone (WCAG 1.4.1). -->
|
|
132
|
+
<template v-if="e.to !== undefined"><template v-if="e.kind === 'door'"> to </template><a :href="`#object/${e.to}`" :class="LINK">{{ name(e.to) }}</a></template>
|
|
133
|
+
<template v-for="(c, j) in e.kind === 'routine' ? e.candidates ?? [] : []" :key="c">{{ j ? ' or ' : ' ' }}<a :href="`#object/${c}`" :class="LINK">{{ name(c) }}</a></template>
|
|
134
|
+
<template v-if="e.kind === 'routine'"> (<a :href="`#routine/${hex(e.routine!)}`" :class="LINK">{{ routineName(story, e.routine!) }}</a>)</template>
|
|
135
|
+
</li>
|
|
136
|
+
</ul>
|
|
137
|
+
<p v-else class="pl-4 text-muted">No exits.</p>
|
|
138
|
+
</li>
|
|
139
|
+
</ul>
|
|
140
|
+
</section>
|
|
141
|
+
</template>
|
|
142
|
+
</div>
|
|
143
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ what: string; back?: string; backLabel?: string }>()
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<a :href="back ?? '#overview'" class="w-fit text-sm text-muted hover:underline">← {{ backLabel ?? 'Overview' }}</a>
|
|
8
|
+
<h1 class="font-display text-3xl font-bold text-highlighted">Nothing here</h1>
|
|
9
|
+
<p class="max-w-2xl text-muted">There's no {{ what }} in this file. ⌘K / Ctrl+K finds any routine, object, string or dictionary word by name or address.</p>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { StoryFile } from '@frotzinc/zcode'
|
|
3
|
+
import type { MachineView } from '../utils/live'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The object tree, as an ARIA tree (WAI-ARIA APG "tree view"): one tab stop;
|
|
7
|
+
* Up/Down move, Right opens or goes to the first child, Left closes or goes to
|
|
8
|
+
* the parent, Home/End, Enter/Space opens the object, and typing a letter
|
|
9
|
+
* jumps to the next object whose name starts with it. Rendered flat, with
|
|
10
|
+
* aria-level, so only open branches are in the DOM.
|
|
11
|
+
*/
|
|
12
|
+
const props = defineProps<{ story: StoryFile; selected?: number; live?: MachineView }>()
|
|
13
|
+
|
|
14
|
+
const objects = computed(() => props.story.objects.objects)
|
|
15
|
+
const count = computed(() => objects.value.length)
|
|
16
|
+
const get = (n: number) => (n >= 1 && n <= count.value ? objects.value[n - 1] : undefined)
|
|
17
|
+
|
|
18
|
+
/** Children of each object, by following child then sibling; a cycle (a broken file) stops the walk. */
|
|
19
|
+
const children = computed(() => {
|
|
20
|
+
const out = new Map<number, number[]>()
|
|
21
|
+
for (const o of objects.value) {
|
|
22
|
+
const list: number[] = []
|
|
23
|
+
const seen = new Set<number>()
|
|
24
|
+
for (let c = o.child; get(c) && !seen.has(c); c = get(c)!.sibling) {
|
|
25
|
+
seen.add(c)
|
|
26
|
+
list.push(c)
|
|
27
|
+
}
|
|
28
|
+
out.set(o.number, list)
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
})
|
|
32
|
+
const roots = computed(() => objects.value.filter(o => !get(o.parent)).map(o => o.number))
|
|
33
|
+
|
|
34
|
+
const open = ref(new Set<number>())
|
|
35
|
+
// Rooms (the roots' children are their contents) start open; so does the path to the selected object.
|
|
36
|
+
watch(roots, r => (open.value = new Set(r.filter(n => (children.value.get(n)?.length ?? 0) > 0))), { immediate: true })
|
|
37
|
+
watch(() => props.selected, (n) => {
|
|
38
|
+
const next = new Set(open.value)
|
|
39
|
+
const seen = new Set<number>()
|
|
40
|
+
for (let p = get(n ?? 0)?.parent ?? 0; get(p) && !seen.has(p); p = get(p)!.parent) {
|
|
41
|
+
seen.add(p)
|
|
42
|
+
next.add(p)
|
|
43
|
+
}
|
|
44
|
+
open.value = next
|
|
45
|
+
}, { immediate: true })
|
|
46
|
+
|
|
47
|
+
interface Row { key: string; n: number; level: number; setSize: number; posInSet: number; parent: number; empty?: boolean }
|
|
48
|
+
/**
|
|
49
|
+
* The visible rows, depth first, without recursion (a deep tree can't overflow the stack). An object
|
|
50
|
+
* shows at most once: in a broken file one could be reached twice, or in a loop.
|
|
51
|
+
*/
|
|
52
|
+
const rows = computed<Row[]>(() => {
|
|
53
|
+
const out: Row[] = []
|
|
54
|
+
// A level's objects are claimed when the level is reached, so its set size and positions count
|
|
55
|
+
// only what is shown there (a broken file can list an object twice, or loop).
|
|
56
|
+
const claimed = new Set<number>()
|
|
57
|
+
const claim = (list: number[]) =>
|
|
58
|
+
list.filter((n) => {
|
|
59
|
+
if (claimed.has(n)) return false
|
|
60
|
+
claimed.add(n)
|
|
61
|
+
return true
|
|
62
|
+
})
|
|
63
|
+
const stack: { list: number[]; i: number; level: number; parent: number }[] = [{ list: claim(roots.value), i: 0, level: 1, parent: 0 }]
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const top = stack.at(-1)!
|
|
66
|
+
if (top.i >= top.list.length) {
|
|
67
|
+
stack.pop()
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
const n = top.list[top.i++]!
|
|
71
|
+
const row: Row = { key: `${top.parent}-${n}`, n, level: top.level, setSize: top.list.length, posInSet: top.i, parent: top.parent }
|
|
72
|
+
out.push(row)
|
|
73
|
+
if (open.value.has(n)) {
|
|
74
|
+
const kids = claim(children.value.get(n) ?? [])
|
|
75
|
+
if (kids.length) stack.push({ list: kids, i: 0, level: top.level + 1, parent: n })
|
|
76
|
+
else row.empty = true // open, but every child is shown elsewhere: not expandable here
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/** The tab stop follows a node, not a row index, so opening or closing a branch above it keeps it. */
|
|
83
|
+
const focusedKey = ref('')
|
|
84
|
+
const focused = computed(() => Math.max(0, rows.value.findIndex(r => r.key === focusedKey.value)))
|
|
85
|
+
watch(() => props.selected, (n) => {
|
|
86
|
+
const r = rows.value.find(x => x.n === n)
|
|
87
|
+
if (r) focusedKey.value = r.key
|
|
88
|
+
}, { immediate: true, flush: 'post' })
|
|
89
|
+
|
|
90
|
+
const tree = useTemplateRef<HTMLElement>('tree')
|
|
91
|
+
function focusRow(i: number) {
|
|
92
|
+
const r = rows.value[Math.max(0, Math.min(rows.value.length - 1, i))]
|
|
93
|
+
if (!r) return
|
|
94
|
+
focusedKey.value = r.key
|
|
95
|
+
nextTick(() => tree.value?.querySelector<HTMLElement>(`[data-row="${focused.value}"]`)?.focus())
|
|
96
|
+
}
|
|
97
|
+
function toggle(n: number) {
|
|
98
|
+
const next = new Set(open.value)
|
|
99
|
+
if (next.has(n)) next.delete(n)
|
|
100
|
+
else next.add(n)
|
|
101
|
+
open.value = next
|
|
102
|
+
}
|
|
103
|
+
const hasChildren = (n: number) => (children.value.get(n)?.length ?? 0) > 0
|
|
104
|
+
const name = (n: number) => objectName(props.story, n)
|
|
105
|
+
const go = (n: number) => (location.hash = `object/${n}`)
|
|
106
|
+
|
|
107
|
+
function onKey(e: KeyboardEvent) {
|
|
108
|
+
const r = rows.value[focused.value]
|
|
109
|
+
if (!r) return
|
|
110
|
+
switch (e.key) {
|
|
111
|
+
case 'ArrowDown': focusRow(focused.value + 1); break
|
|
112
|
+
case 'ArrowUp': focusRow(focused.value - 1); break
|
|
113
|
+
case 'Home': focusRow(0); break
|
|
114
|
+
case 'End': focusRow(rows.value.length - 1); break
|
|
115
|
+
case 'ArrowRight':
|
|
116
|
+
if (!hasChildren(r.n)) return
|
|
117
|
+
if (!open.value.has(r.n)) toggle(r.n)
|
|
118
|
+
else focusRow(focused.value + 1)
|
|
119
|
+
break
|
|
120
|
+
case 'ArrowLeft':
|
|
121
|
+
if (open.value.has(r.n) && hasChildren(r.n)) toggle(r.n)
|
|
122
|
+
else if (r.parent) focusRow(rows.value.findIndex(x => x.n === r.parent))
|
|
123
|
+
else return
|
|
124
|
+
break
|
|
125
|
+
case 'Enter':
|
|
126
|
+
case ' ':
|
|
127
|
+
go(r.n)
|
|
128
|
+
break
|
|
129
|
+
default: {
|
|
130
|
+
// Type-ahead: the next object (after this one) whose name starts with the letter.
|
|
131
|
+
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return
|
|
132
|
+
const k = e.key.toLowerCase()
|
|
133
|
+
const len = rows.value.length
|
|
134
|
+
for (let j = 1; j <= len; j++) {
|
|
135
|
+
const i = (focused.value + j) % len
|
|
136
|
+
if (name(rows.value[i]!.n).toLowerCase().startsWith(k)) {
|
|
137
|
+
focusRow(i)
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
e.preventDefault()
|
|
144
|
+
}
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<template>
|
|
148
|
+
<ul ref="tree" role="tree" aria-label="Object tree" class="flex flex-col py-1 text-sm" @keydown="onKey">
|
|
149
|
+
<li
|
|
150
|
+
v-for="(r, i) in rows"
|
|
151
|
+
:key="r.key"
|
|
152
|
+
role="treeitem"
|
|
153
|
+
:data-row="i"
|
|
154
|
+
:aria-level="r.level"
|
|
155
|
+
:aria-setsize="r.setSize"
|
|
156
|
+
:aria-posinset="r.posInSet"
|
|
157
|
+
:aria-expanded="hasChildren(r.n) && !r.empty ? open.has(r.n) : undefined"
|
|
158
|
+
:aria-selected="r.n === selected"
|
|
159
|
+
:tabindex="i === focused ? 0 : -1"
|
|
160
|
+
class="flex cursor-pointer items-center gap-1 rounded py-1 pr-2 outline-offset-[-2px] hover:bg-elevated"
|
|
161
|
+
:class="r.n === selected ? 'bg-elevated font-semibold text-highlighted' : ''"
|
|
162
|
+
:style="{ paddingLeft: `${(r.level - 1) * 1.1 + 0.25}rem` }"
|
|
163
|
+
@click="focusedKey = r.key; go(r.n)"
|
|
164
|
+
>
|
|
165
|
+
<span
|
|
166
|
+
class="inline-flex size-5 shrink-0 items-center justify-center text-muted"
|
|
167
|
+
aria-hidden="true"
|
|
168
|
+
@click.stop="focusedKey = r.key; hasChildren(r.n) && toggle(r.n)"
|
|
169
|
+
>
|
|
170
|
+
<UIcon v-if="hasChildren(r.n)" :name="open.has(r.n) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" class="size-4" />
|
|
171
|
+
</span>
|
|
172
|
+
<span class="truncate">{{ name(r.n) }}</span>
|
|
173
|
+
<span class="data ml-auto pl-2 text-xs text-muted">{{ r.n }}</span>
|
|
174
|
+
</li>
|
|
175
|
+
</ul>
|
|
176
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { StoryFile } from '@frotzinc/zcode'
|
|
3
|
+
|
|
4
|
+
/** A link to anything with a model Id, labelled as txd would name it. */
|
|
5
|
+
const props = defineProps<{ story: StoryFile; id: string; label?: string }>()
|
|
6
|
+
const text = computed(() => props.label ?? idLabel(props.story, props.id))
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<a :href="idRoute(id)" class="text-lamp underline-offset-2 hover:underline">{{ text }}</a>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { StoryFile } from '@frotzinc/zcode'
|
|
3
|
+
|
|
4
|
+
/** Both directions of the cross-reference index for one Id: what refers to it, and what it refers to. */
|
|
5
|
+
const props = defineProps<{ story: StoryFile; id: string; fromTitle?: string; toTitle?: string }>()
|
|
6
|
+
const entry = computed(() => props.story.xrefs[props.id])
|
|
7
|
+
const from = useLimit(() => entry.value?.from ?? [], 100)
|
|
8
|
+
const to = useLimit(() => entry.value?.to ?? [], 100)
|
|
9
|
+
watch(() => props.id, () => {
|
|
10
|
+
from.reset()
|
|
11
|
+
to.reset()
|
|
12
|
+
})
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<div class="grid gap-6 md:grid-cols-2">
|
|
17
|
+
<section :aria-label="fromTitle ?? 'Referred to from'" class="flex flex-col gap-2">
|
|
18
|
+
<h2 class="font-display text-xl font-semibold text-highlighted">
|
|
19
|
+
{{ fromTitle ?? 'Referred to from' }} <span class="data text-base text-muted">{{ entry?.from.length ?? 0 }}</span>
|
|
20
|
+
</h2>
|
|
21
|
+
<p v-if="!entry?.from.length" class="text-sm text-muted">Nothing in the file refers to this.</p>
|
|
22
|
+
<ul v-else class="flex flex-col gap-1 text-sm">
|
|
23
|
+
<li v-for="(s, i) in from.shown.value" :key="i" class="flex gap-2">
|
|
24
|
+
<RefLink :story="story" :id="s.id" />
|
|
25
|
+
<span class="data text-muted">at {{ hex(s.address) }}</span>
|
|
26
|
+
</li>
|
|
27
|
+
</ul>
|
|
28
|
+
<UButton v-if="from.remaining.value" color="neutral" variant="link" class="w-fit px-0" @click="from.more">
|
|
29
|
+
Show more ({{ from.remaining.value }} left)
|
|
30
|
+
</UButton>
|
|
31
|
+
</section>
|
|
32
|
+
<section :aria-label="toTitle ?? 'Refers to'" class="flex flex-col gap-2">
|
|
33
|
+
<h2 class="font-display text-xl font-semibold text-highlighted">
|
|
34
|
+
{{ toTitle ?? 'Refers to' }} <span class="data text-base text-muted">{{ entry?.to.length ?? 0 }}</span>
|
|
35
|
+
</h2>
|
|
36
|
+
<p v-if="!entry?.to.length" class="text-sm text-muted">This refers to nothing the index tracks.</p>
|
|
37
|
+
<ul v-else class="flex flex-col gap-1 text-sm">
|
|
38
|
+
<li v-for="t in to.shown.value" :key="t"><RefLink :story="story" :id="t" /></li>
|
|
39
|
+
</ul>
|
|
40
|
+
<UButton v-if="to.remaining.value" color="neutral" variant="link" class="w-fit px-0" @click="to.more">
|
|
41
|
+
Show more ({{ to.remaining.value }} left)
|
|
42
|
+
</UButton>
|
|
43
|
+
</section>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { StoryFile } from '@frotzinc/zcode'
|
|
3
|
+
|
|
4
|
+
/** ⌘K / Ctrl+K: jump to any routine, object, string, dictionary word or global, by name or address. */
|
|
5
|
+
const props = defineProps<{ story: StoryFile }>()
|
|
6
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
7
|
+
const { goToId } = useHashRoute()
|
|
8
|
+
const hex4 = (n: number) => n.toString(16).padStart(4, '0')
|
|
9
|
+
const clip = (s: string) => (s.length > 70 ? `${s.slice(0, 70)}…` : s).replace(/\s+/g, ' ')
|
|
10
|
+
|
|
11
|
+
const groups = computed(() => {
|
|
12
|
+
const s = props.story
|
|
13
|
+
const pick = (id: string) => () => {
|
|
14
|
+
open.value = false
|
|
15
|
+
goToId(id)
|
|
16
|
+
}
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
id: 'objects',
|
|
20
|
+
label: 'Objects',
|
|
21
|
+
items: s.objects.objects.map(o => ({ label: o.shortName.text || `(object ${o.number})`, suffix: `#${o.number}`, icon: 'i-lucide-box', onSelect: pick(o.id) })),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'routines',
|
|
25
|
+
label: 'Routines',
|
|
26
|
+
// With names on, a routine is found by its source name, and by its R-number too.
|
|
27
|
+
items: s.code.routines.map((r, i) => {
|
|
28
|
+
const txd = `R${String(i + 1).padStart(4, '0')}`
|
|
29
|
+
const name = routineName(s, r.address)
|
|
30
|
+
return { label: name, ...(name !== txd ? { text: txd } : {}), suffix: hex4(r.address), icon: 'i-lucide-code', onSelect: pick(r.id) }
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'dictionary',
|
|
35
|
+
label: 'Dictionary',
|
|
36
|
+
items: s.dictionary.entries.map(e => ({ label: e.word, suffix: hex4(e.address), icon: 'i-lucide-book-a', onSelect: pick(e.id) })),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'globals',
|
|
40
|
+
label: 'Globals',
|
|
41
|
+
items: s.globals.map(g => ({ label: globalName(g.number), text: `G${g.number.toString(16).padStart(2, '0')}`, suffix: hex4(g.address), icon: 'i-lucide-variable', onSelect: pick(g.id) })),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'strings',
|
|
45
|
+
label: 'Strings',
|
|
46
|
+
// Shown clipped, searched whole: `text` is a search key, not displayed.
|
|
47
|
+
items: s.strings.map(x => ({ label: clip(x.text), text: x.text, suffix: hex4(x.address), icon: 'i-lucide-quote', onSelect: pick(x.id) })),
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
})
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<UModal v-model:open="open" title="Go to" description="Find a routine, object, string, dictionary word or global by name or address.">
|
|
55
|
+
<template #content>
|
|
56
|
+
<UCommandPalette :groups="groups" placeholder="Search by name or address…" class="h-96" :fuse="{ resultLimit: 40, fuseOptions: { keys: ['label', 'suffix', 'text'] } }" @update:open="open = $event" />
|
|
57
|
+
</template>
|
|
58
|
+
</UModal>
|
|
59
|
+
</template>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ value: string | number; label: string; to?: string }>()
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<component
|
|
7
|
+
:is="to ? 'a' : 'div'"
|
|
8
|
+
:href="to"
|
|
9
|
+
class="flex flex-col gap-1 rounded-xl border border-default bg-muted px-5 py-4 transition-colors"
|
|
10
|
+
:class="to ? 'hover:border-[var(--frotz-lamp)]' : ''"
|
|
11
|
+
>
|
|
12
|
+
<span class="data text-3xl font-semibold text-highlighted sm:text-4xl">{{ value }}</span>
|
|
13
|
+
<span class="text-sm text-muted">{{ label }}</span>
|
|
14
|
+
</component>
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** A view's title, what it shows, a way back up (for detail views), and "Copy as text". */
|
|
3
|
+
defineProps<{ title: string; eyebrow?: string; back?: { to: string; label: string }; copy?: () => string; copyLabel?: string }>()
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<header class="flex flex-col gap-3">
|
|
8
|
+
<a v-if="back" :href="back.to" class="w-fit text-sm text-muted hover:underline">← {{ back.label }}</a>
|
|
9
|
+
<p v-if="eyebrow" class="data text-sm text-lamp">{{ eyebrow }}</p>
|
|
10
|
+
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
11
|
+
<h1 class="font-display text-3xl font-bold tracking-tight text-highlighted sm:text-4xl">{{ title }}</h1>
|
|
12
|
+
<CopyText v-if="copy" :text="copy" :label="copyLabel" />
|
|
13
|
+
</div>
|
|
14
|
+
<p v-if="$slots.default" class="max-w-3xl text-muted"><slot /></p>
|
|
15
|
+
</header>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view is the URL hash, so every view can be bookmarked and shared: #overview,
|
|
3
|
+
* #routine/27f8, #object/47, #string/1352c, #dict/215d, #memory/objects.
|
|
4
|
+
* A model Id maps to a route one to one: its first ':' becomes '/'.
|
|
5
|
+
*/
|
|
6
|
+
export interface HashRoute {
|
|
7
|
+
view: string
|
|
8
|
+
id?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const current = ref<HashRoute>({ view: 'overview' })
|
|
12
|
+
|
|
13
|
+
function read(): HashRoute {
|
|
14
|
+
let h: string
|
|
15
|
+
try {
|
|
16
|
+
h = decodeURIComponent(location.hash.replace(/^#/, ''))
|
|
17
|
+
} catch {
|
|
18
|
+
return { view: 'overview' } // a malformed escape (a stray '%'): not a route
|
|
19
|
+
}
|
|
20
|
+
if (!h) return { view: 'overview' }
|
|
21
|
+
const slash = h.indexOf('/')
|
|
22
|
+
return slash < 0 ? { view: h } : { view: h.slice(0, slash), id: h.slice(slash + 1) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let listening = false
|
|
26
|
+
|
|
27
|
+
export function useHashRoute() {
|
|
28
|
+
if (import.meta.client && !listening) {
|
|
29
|
+
listening = true
|
|
30
|
+
current.value = read()
|
|
31
|
+
window.addEventListener('hashchange', () => (current.value = read()))
|
|
32
|
+
}
|
|
33
|
+
/** Go to a route: `go('routine', '27f8')`, or a model Id: `goToId('routine:27f8')`. */
|
|
34
|
+
const go = (view: string, id?: string) => {
|
|
35
|
+
location.hash = id ? `${view}/${id}` : view
|
|
36
|
+
}
|
|
37
|
+
const goToId = (id: string) => {
|
|
38
|
+
const i = id.indexOf(':')
|
|
39
|
+
go(id.slice(0, i), id.slice(i + 1))
|
|
40
|
+
}
|
|
41
|
+
/** Replace the current route without a history entry (replaceState fires no hashchange). */
|
|
42
|
+
const replace = (view: string) => {
|
|
43
|
+
history.replaceState(null, '', `#${view}`)
|
|
44
|
+
current.value = read()
|
|
45
|
+
}
|
|
46
|
+
return { route: current, go, goToId, replace }
|
|
47
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { MAX_DEBUG_FILE, type StoryFile, type Symbols } from '@frotzinc/zcode'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Names from Inform's debug file (gameinfo.dbg), over the story that's open.
|
|
5
|
+
* Dropped anywhere, or chosen on the Symbols view. A debug file written for
|
|
6
|
+
* another story is refused, not applied. `names` (the header's Names/Numbers
|
|
7
|
+
* switch) chooses whether views show them; the story itself never changes.
|
|
8
|
+
*/
|
|
9
|
+
const symbols = shallowRef<Symbols | null>(null)
|
|
10
|
+
const symbolsFile = ref('')
|
|
11
|
+
const symbolsStatus = ref<'none' | 'loading' | 'ready' | 'failed'>('none')
|
|
12
|
+
const symbolsMessage = ref('')
|
|
13
|
+
/** The last file chosen was refused (whatever is loaded stays). */
|
|
14
|
+
const symbolsError = ref(false)
|
|
15
|
+
/** The loaded file says which story it was written for, and it's this one (else: names shown unchecked). */
|
|
16
|
+
const symbolsVerified = ref(false)
|
|
17
|
+
const names = ref(true)
|
|
18
|
+
let generation = 0
|
|
19
|
+
|
|
20
|
+
/** The symbols the views should use: loaded, for this story, and switched on. */
|
|
21
|
+
export const activeSymbols = computed<Symbols | undefined>(() => (names.value && symbols.value ? symbols.value : undefined))
|
|
22
|
+
|
|
23
|
+
/** A debug file by its look: Inform's are named *.dbg, and are XML. */
|
|
24
|
+
export const isDebugFile = (file: File) => /\.dbg$/i.test(file.name)
|
|
25
|
+
|
|
26
|
+
export function useSymbols() {
|
|
27
|
+
async function loadSymbols(file: File, story: StoryFile) {
|
|
28
|
+
const mine = ++generation
|
|
29
|
+
symbolsStatus.value = 'loading'
|
|
30
|
+
symbolsMessage.value = ''
|
|
31
|
+
symbolsError.value = false
|
|
32
|
+
try {
|
|
33
|
+
// Checked before reading: an oversized file is never loaded into memory.
|
|
34
|
+
if (file.size > MAX_DEBUG_FILE) throw new Error(`It is ${Math.round(file.size / 1048576)} MB, more than a debug file can be.`)
|
|
35
|
+
const [{ readSymbols, symbolsMatch }, text] = await Promise.all([import('@frotzinc/zcode'), file.text()])
|
|
36
|
+
if (mine !== generation) return
|
|
37
|
+
const s = readSymbols(text)
|
|
38
|
+
const usable = Object.keys(s.routines).length > 0 || Object.keys(s.objects).length > 0
|
|
39
|
+
if (!usable) throw new Error(s.problems[0] ?? 'It has no names in it.')
|
|
40
|
+
const match = symbolsMatch(story.bytes, s)
|
|
41
|
+
if (match === false) throw new Error(`${file.name} was written for another story file, so its names would be wrong here.`)
|
|
42
|
+
symbols.value = markRaw(s)
|
|
43
|
+
symbolsFile.value = file.name
|
|
44
|
+
symbolsStatus.value = 'ready'
|
|
45
|
+
symbolsVerified.value = match === true
|
|
46
|
+
names.value = true
|
|
47
|
+
// A file with no story prefix can't be checked: its names are shown, and that is said.
|
|
48
|
+
symbolsMessage.value = [match === undefined ? `${file.name} doesn't say which story it was written for, so its names can't be checked against this one.` : '', ...s.problems].filter(Boolean).join(' ')
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (mine !== generation) return
|
|
51
|
+
symbolsStatus.value = symbols.value ? 'ready' : 'failed'
|
|
52
|
+
symbolsError.value = true
|
|
53
|
+
symbolsMessage.value = `${file.name} couldn't be used: ${e instanceof Error ? e.message : String(e)}`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clearSymbols() {
|
|
58
|
+
generation++
|
|
59
|
+
symbols.value = null
|
|
60
|
+
symbolsFile.value = ''
|
|
61
|
+
symbolsStatus.value = 'none'
|
|
62
|
+
symbolsMessage.value = ''
|
|
63
|
+
symbolsError.value = false
|
|
64
|
+
symbolsVerified.value = false
|
|
65
|
+
names.value = true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { symbols, symbolsFile, symbolsStatus, symbolsMessage, symbolsError, symbolsVerified, names, loadSymbols, clearSymbols }
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A running machine, as a view sees it: where it is, and what changed since it last stopped.
|
|
3
|
+
*
|
|
4
|
+
* A placeholder until frotztrace's Phase 3, when @frotzinc/zmachine exports the real type and this
|
|
5
|
+
* file re-exports it. Every view takes it as an optional `live` prop; without one (frotzlens, a
|
|
6
|
+
* story file at rest) a view renders exactly as it always has.
|
|
7
|
+
*/
|
|
8
|
+
export interface MachineView {
|
|
9
|
+
/** The program counter: the next instruction to run. */
|
|
10
|
+
pc?: number
|
|
11
|
+
/** Index of the frame being looked at (0 = the outermost). */
|
|
12
|
+
frame?: number
|
|
13
|
+
/** What the machine wrote since the last stop. */
|
|
14
|
+
changed?: {
|
|
15
|
+
addresses: ReadonlySet<number>
|
|
16
|
+
objects: ReadonlySet<number>
|
|
17
|
+
globals: ReadonlySet<number>
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid placement for the map: Trizbort's rule, as frotzsmith's map draws it
|
|
3
|
+
* (ported from frotzsmith's app/composables/map-graph.ts, by the same author,
|
|
4
|
+
* keyed by object number here instead of room name). The start room sits at
|
|
5
|
+
* (0, 0); each exit puts its room one step away in its compass direction,
|
|
6
|
+
* or the nearest free cell when that one is taken; up, down, in and out go
|
|
7
|
+
* to the nearest free cell. Rooms no exit reaches start a group of their own.
|
|
8
|
+
*/
|
|
9
|
+
import type { Direction, RoomGraph } from '@frotzinc/zcode'
|
|
10
|
+
|
|
11
|
+
export interface PlacedRoom { number: number; name: string; col: number; row: number }
|
|
12
|
+
/** grid: drawn on its compass step; routine: an exit a routine decides (drawn dashed). */
|
|
13
|
+
export interface Connector { from: number; to: number; dir: Direction; grid: boolean; routine: boolean }
|
|
14
|
+
export interface MapLayout {
|
|
15
|
+
rooms: PlacedRoom[]
|
|
16
|
+
connectors: Connector[]
|
|
17
|
+
bounds: { minCol: number; maxCol: number; minRow: number; maxRow: number }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DELTA: Record<Direction, [number, number]> = {
|
|
21
|
+
n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0],
|
|
22
|
+
ne: [1, -1], nw: [-1, -1], se: [1, 1], sw: [-1, 1],
|
|
23
|
+
u: [0, 0], d: [0, 0], in: [0, 0], out: [0, 0],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Each exit that reaches a room, or for a routine exit each room it can return: what the map draws. */
|
|
27
|
+
export function mapEdges(g: RoomGraph): { from: number; to: number; dir: Direction; routine: boolean }[] {
|
|
28
|
+
const rooms = new Set(g.rooms.map(r => r.number))
|
|
29
|
+
return g.exits.flatMap(e =>
|
|
30
|
+
(e.to !== undefined ? [e.to] : (e.candidates ?? [])).filter(t => rooms.has(t)).map(to => ({ from: e.from, to, dir: e.dir, routine: e.kind === 'routine' })),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function layoutMap(g: RoomGraph): MapLayout {
|
|
35
|
+
const edges = mapEdges(g)
|
|
36
|
+
const pos = new Map<number, [number, number]>()
|
|
37
|
+
const occupied = new Set<string>()
|
|
38
|
+
const key = (c: number, r: number) => `${c},${r}`
|
|
39
|
+
const place = (room: number, c: number, r: number) => {
|
|
40
|
+
pos.set(room, [c, r])
|
|
41
|
+
occupied.add(key(c, r))
|
|
42
|
+
}
|
|
43
|
+
// Rings outward until a free cell: there are only so many rooms, so one always turns up.
|
|
44
|
+
const nearestFree = (c: number, r: number): [number, number] => {
|
|
45
|
+
for (let ring = 0; ; ring++)
|
|
46
|
+
for (let dc = -ring; dc <= ring; dc++)
|
|
47
|
+
for (let dr = -ring; dr <= ring; dr++) {
|
|
48
|
+
if (Math.max(Math.abs(dc), Math.abs(dr)) !== ring) continue
|
|
49
|
+
if (!occupied.has(key(c + dc, r + dr))) return [c + dc, r + dr]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const compass = (e: { dir: Direction }) => DELTA[e.dir][0] !== 0 || DELTA[e.dir][1] !== 0
|
|
53
|
+
/**
|
|
54
|
+
* Place rooms from placed ones until nothing more can be. Compass exits go first, each to its step
|
|
55
|
+
* (or the nearest free cell); only when none is left does one up/down/in/out exit place its room,
|
|
56
|
+
* so a room any compass exit reaches is put by that, whatever order the exits come in.
|
|
57
|
+
*/
|
|
58
|
+
const relax = () => {
|
|
59
|
+
for (;;) {
|
|
60
|
+
let changed = false
|
|
61
|
+
for (const e of edges) {
|
|
62
|
+
const from = pos.get(e.from)
|
|
63
|
+
if (!from || pos.has(e.to) || !compass(e)) continue
|
|
64
|
+
const [dc, dr] = DELTA[e.dir]
|
|
65
|
+
if (!occupied.has(key(from[0] + dc, from[1] + dr))) place(e.to, from[0] + dc, from[1] + dr)
|
|
66
|
+
else place(e.to, ...nearestFree(from[0] + dc, from[1] + dr))
|
|
67
|
+
changed = true
|
|
68
|
+
}
|
|
69
|
+
if (changed) continue
|
|
70
|
+
const e = edges.find(x => pos.has(x.from) && !pos.has(x.to))
|
|
71
|
+
if (!e) return
|
|
72
|
+
const from = pos.get(e.from)!
|
|
73
|
+
place(e.to, ...nearestFree(from[0], from[1]))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const first = g.start ?? g.rooms[0]?.number
|
|
77
|
+
if (first !== undefined) place(first, 0, 0)
|
|
78
|
+
relax()
|
|
79
|
+
for (const r of g.rooms) {
|
|
80
|
+
if (pos.has(r.number)) continue
|
|
81
|
+
place(r.number, ...nearestFree(0, 0))
|
|
82
|
+
relax()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const connectors = edges.map(e => {
|
|
86
|
+
const a = pos.get(e.from)!
|
|
87
|
+
const b = pos.get(e.to)!
|
|
88
|
+
const [dc, dr] = DELTA[e.dir]
|
|
89
|
+
return { ...e, grid: (dc !== 0 || dr !== 0) && b[0] === a[0] + dc && b[1] === a[1] + dr }
|
|
90
|
+
})
|
|
91
|
+
const placed = [...pos.values()]
|
|
92
|
+
const cols = placed.map(p => p[0])
|
|
93
|
+
const rows = placed.map(p => p[1])
|
|
94
|
+
const bounds = placed.length
|
|
95
|
+
? { minCol: Math.min(...cols), maxCol: Math.max(...cols), minRow: Math.min(...rows), maxRow: Math.max(...rows) }
|
|
96
|
+
: { minCol: 0, maxCol: 0, minRow: 0, maxRow: 0 }
|
|
97
|
+
return { rooms: g.rooms.map(r => ({ ...r, col: pos.get(r.number)![0], row: pos.get(r.number)![1] })), connectors, bounds }
|
|
98
|
+
}
|
package/app/utils/nav.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** One entry in AppNav: a view, by its route key. */
|
|
2
|
+
export interface NavView {
|
|
3
|
+
key: string
|
|
4
|
+
label: string
|
|
5
|
+
icon: string
|
|
6
|
+
/** How many things the view lists, shown at the right. */
|
|
7
|
+
count?: number
|
|
8
|
+
/** A word shown at the right instead of a count. */
|
|
9
|
+
tag?: string
|
|
10
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names for the things in a story, as txd and infodump print them (R0001,
|
|
3
|
+
* S001, an object's short name, a dictionary word), and the hash route for any
|
|
4
|
+
* model Id. Worked out once per story and cached.
|
|
5
|
+
*/
|
|
6
|
+
import type { StoryFile } from '@frotzinc/zcode'
|
|
7
|
+
import { txdRoutineName, txdStringName } from '@frotzinc/zcode/format'
|
|
8
|
+
import { activeSymbols } from '../composables/useSymbols'
|
|
9
|
+
|
|
10
|
+
export const hex = (n: number, width = 4) => n.toString(16).padStart(width, '0')
|
|
11
|
+
|
|
12
|
+
/** The hash route for a model Id: `routine:27f8` → `#routine/27f8`. */
|
|
13
|
+
export const idRoute = (id: string) => `#${id.replace(':', '/')}`
|
|
14
|
+
|
|
15
|
+
interface Index {
|
|
16
|
+
routineNumber: Map<number, number>
|
|
17
|
+
stringNumber: Map<number, number>
|
|
18
|
+
stringByAddress: Map<number, { text: string }>
|
|
19
|
+
dictByAddress: Map<number, { word: string }>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cache = new WeakMap<StoryFile, Index>()
|
|
23
|
+
|
|
24
|
+
export function storyIndex(story: StoryFile): Index {
|
|
25
|
+
let i = cache.get(story)
|
|
26
|
+
if (!i) {
|
|
27
|
+
i = {
|
|
28
|
+
routineNumber: new Map(story.code.routines.map((r, n) => [r.address, n + 1])),
|
|
29
|
+
stringNumber: new Map(story.strings.map((s, n) => [s.address, n + 1])),
|
|
30
|
+
stringByAddress: new Map(story.strings.map(s => [s.address, s])),
|
|
31
|
+
dictByAddress: new Map(story.dictionary.entries.map(e => [e.address, e])),
|
|
32
|
+
}
|
|
33
|
+
cache.set(story, i)
|
|
34
|
+
}
|
|
35
|
+
return i
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A routine's name: its source name when a debug file is loaded and names are on, else txd's
|
|
40
|
+
* (R0001), from zcode, so a link reads exactly as the disassembly does.
|
|
41
|
+
*/
|
|
42
|
+
export const routineName = (story: StoryFile, address: number) => activeSymbols.value?.routines[address]?.name ?? txdRoutineName(story, address)
|
|
43
|
+
export const stringName = txdStringName
|
|
44
|
+
|
|
45
|
+
/** A global: its source name with symbols, else txd's G00. */
|
|
46
|
+
export const globalName = (n: number) => activeSymbols.value?.globals[n] ?? `G${hex(n, 2)}`
|
|
47
|
+
/** An attribute, property or action number, with its source name when there is one. */
|
|
48
|
+
export const attributeName = (n: number) => activeSymbols.value?.attributes[n]
|
|
49
|
+
export const propertyName = (n: number) => activeSymbols.value?.properties[n]
|
|
50
|
+
export const actionName = (n: number) => activeSymbols.value?.actions[n]
|
|
51
|
+
|
|
52
|
+
export const objectName = (story: StoryFile, n: number) =>
|
|
53
|
+
story.objects.objects[n - 1]?.shortName.text || `object ${n}`
|
|
54
|
+
|
|
55
|
+
export const clip = (s: string, n = 60) => {
|
|
56
|
+
const one = s.replace(/\s+/g, ' ')
|
|
57
|
+
return one.length > n ? `${one.slice(0, n)}…` : one
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A short, readable label for any model Id. */
|
|
61
|
+
export function idLabel(story: StoryFile, id: string): string {
|
|
62
|
+
const [kind, rest = ''] = id.split(':')
|
|
63
|
+
const n = parseInt(rest, kind === 'object' || kind === 'global' || kind === 'abbrev' ? 10 : 16)
|
|
64
|
+
switch (kind) {
|
|
65
|
+
case 'routine': return routineName(story, n)
|
|
66
|
+
case 'string': {
|
|
67
|
+
const s = storyIndex(story).stringByAddress.get(n)
|
|
68
|
+
return s ? `${stringName(story, n)} “${clip(s.text, 40)}”` : stringName(story, n)
|
|
69
|
+
}
|
|
70
|
+
case 'object': return `${objectName(story, n)} (#${n})`
|
|
71
|
+
case 'dict': {
|
|
72
|
+
const e = storyIndex(story).dictByAddress.get(n)
|
|
73
|
+
return e ? `“${e.word}”` : `word at ${hex(n)}`
|
|
74
|
+
}
|
|
75
|
+
case 'global': return globalName(n)
|
|
76
|
+
default: return id
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Show a long list a page at a time: `shown` is the first `limit`, `more()` adds a page. */
|
|
81
|
+
export function useLimit<T>(list: () => T[], step = 200) {
|
|
82
|
+
const limit = ref(step)
|
|
83
|
+
const shown = computed(() => list().slice(0, limit.value))
|
|
84
|
+
const remaining = computed(() => Math.max(0, list().length - limit.value))
|
|
85
|
+
const more = () => (limit.value += step)
|
|
86
|
+
return { shown, remaining, more, reset: () => (limit.value = step) }
|
|
87
|
+
}
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @frotzinc/zcode-ui — the Z-machine story views shared by frotzlens and frotztrace, as a Nuxt layer.
|
|
3
|
+
*
|
|
4
|
+
* export default defineNuxtConfig({ extends: ['@frotzinc/nuxt-base', '@frotzinc/zcode-ui'] })
|
|
5
|
+
*
|
|
6
|
+
* Gives, auto-imported: the views (ObjectTree, GlobalsView, MapView, Disassembly, HexPane,
|
|
7
|
+
* SearchPalette, AppNav) and their parts (RefLink, Referrers, ViewHeader, CopyText, StatTile,
|
|
8
|
+
* NotFound); the composables useHashRoute and useSymbols; and the utils in app/utils (hex,
|
|
9
|
+
* routineName, idLabel, layoutMap, …). It adds no colors, fonts or CSS: the look is
|
|
10
|
+
* @frotzinc/nuxt-base's, so every frotz, Inc. app looks the same. The app lists @nuxt/ui in `modules`.
|
|
11
|
+
*/
|
|
12
|
+
export default defineNuxtConfig({})
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frotzinc/zcode-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Z-machine story views of frotzlens as a Nuxt layer: object tree, globals, map, disassembly, hex, go-to palette and the pieces they share, for frotzlens and frotztrace.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Christopher Schweda",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"homepage": "https://github.com/cschweda/frotzlens/tree/main/packages/zcode-ui",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/cschweda/frotzlens.git",
|
|
12
|
+
"directory": "packages/zcode-ui"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/cschweda/frotzlens/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"z-machine",
|
|
19
|
+
"interactive-fiction",
|
|
20
|
+
"nuxt",
|
|
21
|
+
"nuxt-layer",
|
|
22
|
+
"nuxt-ui",
|
|
23
|
+
"frotzinc"
|
|
24
|
+
],
|
|
25
|
+
"main": "./nuxt.config.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./nuxt.config.ts",
|
|
28
|
+
"./nuxt.config.ts": "./nuxt.config.ts",
|
|
29
|
+
"./utils/*": "./app/utils/*.ts",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"nuxt.config.ts",
|
|
34
|
+
"app",
|
|
35
|
+
"!**/*.test.ts",
|
|
36
|
+
"CHANGELOG.md",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@frotzinc/zcode": "0.4.1"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@nuxt/ui": "^4.12.0",
|
|
45
|
+
"nuxt": "^4.6.0",
|
|
46
|
+
"vue": "^3.5.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"vitest": "^5.1.0",
|
|
50
|
+
"typescript": "5.9.3"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
55
|
+
}
|
|
6
56
|
}
|