vn
The visual-novel layer of *Amagiri: Hollow Bell*, as a general system. It knows nothing about any particular character: it runs whatever script was handed to `Game.registerDialogue`, draws whatever portrait was handed to `Game.registerPortrait`, and reports what happened over the…
vn — the conversation layer
The visual-novel layer of Amagiri: Hollow Bell, as a general system. It knows
nothing about any particular character: it runs whatever script was handed to
Game.registerDialogue, draws whatever portrait was handed to
Game.registerPortrait, and reports what happened over the event bus.
What it puts on screen
Over a dimmed, blurred view of the valley:
- a portrait on the left, swapping frame whenever a line declares a
different
emotion; - a name plate tabbed into the top of the box;
- a translucent dialogue box whose line types out and can be skipped to full;
- a vertical choice list, keyboard- and pointer-selectable, that appears once the line has finished typing.
The layer desaturates with Game.fading(), so a conversation held late looks
as drained as the valley around it.
Driving it
local Game = require("game.Game")
Game.registerPortrait("kagen", { sheet = tex, cols = 5, rows = 1,
frames = { neutral = 1, warm = 2, hurt = 3, angry = 4, broken = 5 } })
-- or: Game.registerPortrait("kagen", { images = { neutral = tex, ... } })
Game.registerDialogue("kagen", {
speaker = "Kagen", portrait = "kagen", start = "n1", startFaded = "f1",
nodes = { n1 = { text = "...", emotion = "angry", next = "n2" } },
})
Game.startDialogue("kagen")
Both portrait shapes are supported. A sheet is cropped by frame index,
row-major and 1-based, from frames[<emotion>]; an emotion with no frame falls
back to neutral, and a portrait key nobody registered draws a legible
placeholder that still tracks the emotion, so a script is testable before its
art exists.
A portrait built from one image per emotion draws its whole set from the first frame, all but the current one at zero opacity. A texture materialises the first time it is asked for, and asking for a new one exactly when the line changes puts the engine's not-yet-loaded placeholder on screen for a frame or two, at the moment the reader is looking straight at the face. Paying for the set while the box opens costs one draw call each and nothing after that.
The node schema is the one game.Game documents — text, emotion,
speaker, next, choices, grant, set, done. A choice may carry
next, set and done. A node with none of next / choices / done ends
the conversation with outcome "end"; a node pointing at a node id that does
not exist ends it with "missing-node" and says so in the log, rather than
wedging with the world's input still locked.
startFaded is used instead of start when Game.fading() is past
Game.tuning.fadedThreshold.
Controls
amagiri_vn.inputMap, beside this module, carries all three device classes:
| Control | Keyboard | Gamepad | Touch |
|---|---|---|---|
advance | Space / Enter / E | south | a lower-right button |
choiceUp | ↑ / W | dpad up | — the choices are tappable |
choiceDown | ↓ / S | dpad down | — |
The map is live only while a conversation is open. It sits in group ui and
stands the player group down, and Game.setInputLock(true) is held for
exactly the same span, so traversal has both signals.
Mid-line, advance skips the reveal. With the line complete and choices up, it
confirms the highlighted one. Clicking the box advances; clicking a choice row
picks it.
Events
| Emitted | When |
|---|---|
dialogue:start { id } | the box opens |
dialogue:line { id, node, emotion } | each node is entered |
dialogue:end { id, outcome } | the box closes |
camera:focus { id, position, lookAt, fov } | asks traversal for the two-shot |
camera:release {} | gives the camera back |
The two-shot is computed from the speaker's registered interactable position
and where the camera already is, so it swings a little off the player's own
line of sight instead of cutting to a fixed compass bearing. A speaker with no
interactable gets a camera:focus carrying only id and fov.
Reading it back
require("vn.vn").status()
--> { open, dialogue, node, speaker, emotion, typing, shown, total,
-- choiceIndex, choices, inputLocked, mapLive, lastOutcome, builds }
lastOutcome survives the conversation that produced it, so a caller that
arrives after the box has closed can still see how it ended. builds counts
remounts, which is how you tell "the layer reset under me" from "the
conversation ended".
The layer claims its surfaces — the widget-callback environment, the dialogue
runner, the run-reset subscription — both from build() and once at module
load. Editing this file reloads the module without going back through
Game.rebuild, and a runner whose pointer no longer answers looks exactly like
one that works right up until somebody clicks a choice.
Vn.advance(), Vn.select(±1) and Vn.choose(i) are the same entry points the
controls and the pointer use, so a test drives the layer exactly as a player
does without simulating a device.
Proving it
vn.vn_selftest is this slice's own throwaway script — one voice named Echo
that exists only to exercise every branch, with portraits registered in both
shapes from the art in art/:
local T = require("vn.vn_selftest")
T.install() -- or T.install(T.IMAGES_KEY)
require("game.Game").startDialogue(T.SCRIPT_ID)
Nothing loads it at boot; the slice stays character-blind.
Files
| Path | What |
|---|---|
vn.module | the runner, the state machine, the slice lifecycle |
vn_view.module | the widget tree and the portrait renderer |
vn_selftest.module | the throwaway script and portrait registrations |
amagiri_vn.inputMap | Continue / Previous choice / Next choice |
art/ | the self-test's five emotion frames, as a sheet and as singles |
Scoped to this part · feeds back into the world's score.